14 Commits

73 changed files with 4670 additions and 2783 deletions

View File

@@ -1,10 +1,10 @@
# weatherreporter # weatherreporter
`weatherreporter` is a Go application for preparing human-facing weather `weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data. It builds structured briefing packages, reports from normalized forecast data. It builds JSON module snapshots, passes
runs them through `scriptorium`, and keeps inspectable artifacts under a local YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
workspace. It can also upload successfully generated managed Markdown reports under a local workspace. It can also upload successfully generated managed
to a configured `distributor` HTTP upload endpoint. Markdown reports to a configured `distributor` HTTP upload endpoint.
## Quickstart ## Quickstart

View File

@@ -1,7 +1,7 @@
# Weatherreporter CLI # Weatherreporter CLI
`weatherreporter` generates Markdown weather reports, runs scheduled report `weatherreporter` generates Markdown weather reports, runs scheduled report
batches, and inspects previously generated artifacts. batches, and inspects stored artifacts.
## Shortest Useful Command ## Shortest Useful Command
@@ -28,17 +28,17 @@ weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-d
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter inspect reports [--config PATH] [--limit N] weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect briefing [--config PATH] RUN_ID weatherreporter inspect modules [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID
``` ```
`generate` commands write briefing, data package, preflight, report, and `generate` commands write a JSON module snapshot, YAML data package, preflight
metadata artifacts under the configured workspace. `--out` writes an extra artifact, managed Markdown report, and metadata under the configured workspace.
Markdown copy for the operator; distributor notification uses the managed `--out` writes an extra Markdown copy for the operator; distributor
report path, not the extra copy. `generate storm` requires explicit notification uses the managed report path, not the extra copy. `generate storm`
event-window bounds with `--start` and `--end`. requires explicit event-window bounds with `--start` and `--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
@@ -88,14 +88,15 @@ weatherreporter run evening --out-dir ./reports
```sh ```sh
weatherreporter inspect reports --limit 10 weatherreporter inspect reports --limit 10
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
weatherreporter inspect briefing 20260529T100000.000000000Z_daily_today weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
``` ```
`inspect reports` lists recent generated runs with artifact paths and source `inspect reports` lists recent generated runs with artifact paths and source
warning counts. The other inspect commands require a RunID. `inspect prior` warning counts. The other inspect commands require a RunID. `inspect modules`
returns the persisted ordered module snapshot for a run. `inspect prior`
returns the prior comparable snapshot metadata selected from stored metadata, or returns the prior comparable snapshot metadata selected from stored metadata, or
`null` when none exists. `inspect sources` shows source provenance and source `null` when none exists. `inspect sources` shows source provenance and source
warnings without dumping full weather payloads. warnings without dumping full weather payloads.

View File

@@ -16,9 +16,9 @@ Precedence is:
2. configuration file 2. configuration file
3. built-in defaults 3. built-in defaults
The implemented configuration overrides are `--units` and `--tz`. Output flags The CLI configuration overrides are `--units` and `--tz`. Output flags control
control report copies for the current command but do not change configuration report copies for the current command but do not change configuration files.
files. Environment variables do not override configuration fields. Environment variables do not override configuration fields.
## Minimal Config ## Minimal Config
@@ -53,7 +53,7 @@ Timezone values may be IANA names, configured aliases such as `Chicago` and
### `location` ### `location`
`location` is descriptive prompt context included in briefing metadata and `location` is descriptive prompt context included in module metadata and
Scriptorium data packages. It does not select a Weather API endpoint or enable Scriptorium data packages. It does not select a Weather API endpoint or enable
multiple configured forecast locations. multiple configured forecast locations.
@@ -143,7 +143,7 @@ stub source slots use the missing-source policy.
### `workspace` ### `workspace`
- `root`: workspace root for managed artifacts. Default: `workspace`. - `root`: workspace root for managed artifacts. Default: `workspace`.
- `snapshots_dir`: briefing and metadata directory under `workspace.root`. Default: `snapshots`. - `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`. - `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`. - `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`. - `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
@@ -171,9 +171,47 @@ midday, afternoon, and evening.
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`. - `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`. - `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
Recent Changes are added to prompt input when a prior comparable briefing Recent Changes are added to prompt input when a prior comparable module
snapshot exists and a threshold is crossed. snapshot exists and a threshold is crossed.
### `reports`
`reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
also accepted.
Each report entry supports:
- `deterministic_modules`: ordered module list. Entries may be string module
IDs or objects with `id` and optional `options`.
Example:
```yaml
reports:
daily:
deterministic_modules:
- metadata
- current_conditions
- id: area_forecast_discussion
options:
sections:
- short_term
```
Unknown reports, unknown modules, duplicate modules, incompatible report/module
combinations, duplicate stanza names, and invalid options fail config loading.
`area_forecast_discussion.options.sections` may contain `product`,
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
includes all available AFD sections.
The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Modules without builders are
valid in composition but do not emit YAML stanzas.
## Secrets ## Secrets
Configuration files should not contain raw secrets. Use `secrets.directory` to Configuration files should not contain raw secrets. Use `secrets.directory` to
@@ -189,6 +227,6 @@ notification, this allows a file such as
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest - [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
useful config for generation and fetching. useful config for generation and fetching.
- [examples/config.yml](../examples/config.yml): production-oriented config - [examples/config.yml](../examples/config.yml): production-oriented config
covering implemented fields. covering maintained fields.
Both example files are loaded by the config test suite. Both example files are loaded by the config test suite.

View File

@@ -30,8 +30,9 @@ scriptorium run \
``` ```
`weatherreporter` always passes prompt input as `weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured JSON created by `--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`. `internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes.
## Configured Arguments ## Configured Arguments

View File

@@ -6,7 +6,7 @@ This document describes the external Weather API contract used by
## Purpose ## Purpose
`weatherreporter` uses a configured Weather API base URL to fetch normalized `weatherreporter` uses a configured Weather API base URL to fetch normalized
weather source data and assemble a `forecast.Bundle`. This is an integration weather source data and assemble a `weatherdata.Bundle`. This is an integration
contract for the project adapter, not a complete public API reference for the contract for the project adapter, not a complete public API reference for the
upstream service. upstream service.
@@ -45,8 +45,7 @@ they fail the fetch; optional malformed sources follow the missing-source policy
The adapter sends these query parameters: The adapter sends these query parameters:
- `format`: from `weather_api.format`; the implemented configuration requires - `format`: from `weather_api.format`; configuration validation requires `json`
`json`
- `units`: from `weather_api.units` - `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current - `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests conditions, hourly forecast, and narrative forecast requests

View File

@@ -1,15 +1,15 @@
# App Orchestration Internals # App Orchestration Internals
This document describes the implemented workflow coordinator in `internal/app`. This document describes the workflow coordinator in `internal/app`.
## Purpose ## Purpose
`internal/app` coordinates the top-level use cases after CLI parsing and config `internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data, loading are complete. It resolves report definitions, fetches weather data,
builds briefing and prompt-input artifacts, invokes Scriptorium through the builds collected and derived facts, builds module snapshots and prompt-input
adapter boundary, optionally notifies distributor through an app-owned notifier artifacts, invokes Scriptorium through the adapter boundary, optionally
boundary, persists managed state, runs batches, and reads existing artifacts for notifies distributor through an app-owned notifier boundary, persists managed
inspection. state, runs batches, and reads existing artifacts for inspection.
## Inputs And Outputs ## Inputs And Outputs
@@ -18,32 +18,32 @@ Inputs:
- `GenerateRequest` for one report command - `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands - `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle fetch and save workflows - `FetchBundleRequest` for explicit bundle fetch and save workflows
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests - `ReportRequest` for single-report generation
and internal composition
- resolved report definitions from `internal/report` - resolved report definitions from `internal/report`
- forecast bundles from `internal/adapters/weatherapi` - weather data bundles from `internal/adapters/weatherapi`
- prior snapshots loaded from `internal/state` - prior snapshots loaded from `internal/state`
- optional renderer, notifier, and state-store fakes for tests - optional renderer, notifier, and state-store fakes for tests
Outputs: Outputs:
- generated report results with briefing, data package, preflight, report, - generated report results with JSON module snapshot, YAML data package,
metadata, prior snapshot, Recent Changes, Scriptorium result details, and preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
notification result when attempted result details, 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
- inspection JSON values for reports, metadata, briefings, data packages, prior - inspection JSON values for reports, metadata, module snapshots, data
snapshots, and source provenance packages, prior snapshots, and source provenance
## Boundaries ## Boundaries
`internal/app` owns workflow order and request composition. It does not parse `internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, derive forecast CLI flags, load YAML files directly, implement HTTP transport, own fact
facts, define report periods, compare rendered Markdown, or construct derivation algorithms, define report periods, compare rendered Markdown, or
Scriptorium argv. construct Scriptorium argv.
Report selection and report identity policy come from `internal/report`. Report selection and report identity policy come from `internal/report`.
Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor 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
@@ -51,7 +51,7 @@ persisted metadata stay in `internal/state`.
## Config Fields Used ## Config Fields Used
- `weather_api.*` for Weather API client construction and briefing metadata - `weather_api.*` for Weather API client construction and module metadata
- `scriptorium.*` for renderer construction - `scriptorium.*` for renderer construction
- `workspace.*` for filesystem state - `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization - `dayparts` for daily and outlook summarization
@@ -69,10 +69,10 @@ Single-report generation follows this order:
2. Create or use a filesystem store. 2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`. 3. Locate any prior compatible snapshot through `internal/state`.
4. Fetch a Weather API bundle. 4. Fetch a Weather API bundle.
5. Build a report-specific briefing package. 5. Build collected and derived facts once.
6. Save the briefing snapshot. 6. Execute configured modules and save the module snapshot.
7. Compute Recent Changes from structured prior and current briefings. 7. Compute Recent Changes from structured prior and current module snapshots.
8. Build and save the Scriptorium `data_package`. 8. Build and save the YAML Scriptorium `data_package`.
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.
@@ -88,7 +88,8 @@ 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
generation returns an error after writing output, the managed report and 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,
briefing, prompt input, render, Scriptorium run, or metadata-save failures. module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures.
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
@@ -117,7 +118,8 @@ inspection view.
## Failure Behavior ## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data. - Resolve errors stop the requested workflow before fetching weather data.
- Weather API and briefing errors stop that report before Scriptorium runs. - Weather API and module execution errors stop that report before Scriptorium
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.
- Notification errors are wrapped with report ID, RunID, and managed report path - Notification errors are wrapped with report ID, RunID, and managed report path
@@ -140,7 +142,7 @@ 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.
- Recent Changes are computed from structured briefing 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
bundle paths; extra output copies are not upload sources. bundle paths; extra output copies are not upload sources.

View File

@@ -1,47 +1,60 @@
# Briefing Internals # Module Builder Internals
This document describes the implemented briefing package boundary. This document describes module builder behavior in `internal/briefing`.
## Purpose ## Purpose
`internal/briefing` builds structured report-specific briefing packages from `internal/briefing` turns report metadata, collected weather data, and derived
resolved report metadata, forecast bundles, and derived forecast summaries. forecast facts into prompt-facing module outputs. The package also owns the
Briefings are curated inputs for prompt data packages, not rendered report module registry used to validate report composition and config overrides.
prose.
Module outputs are structured prompt inputs. They are not rendered report prose
and they are not persisted by this package.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- resolved report definition, generation time, timezone, and valid period - resolved report definition, generation time, timezone, and valid period
- forecast bundle with source provenance and warnings - collected facts built from `weatherdata.Bundle`
- derived daily or period summaries where required - derived daily, daypart, precipitation, alert, and storm-window facts where
required
- configured units, timezone, and descriptive location context - configured units, timezone, and descriptive location context
- typed module options from report defaults or config overrides
Outputs: Outputs:
- `briefing.Package` with common metadata and one report-specific content - `ModuleDefinition` values with module ID, stanza name, option type,
object for Daily, 3-Day, Weekend, or Storm Report supported reports, fact requirements, missing-data behavior, and builder
- optional `currentConditions` prompt context from normalized - `module.Output` values for source-oriented stanzas:
`/conditions/current` data when available `metadata`, `current_conditions`, `alert_digest`,
- optional structured `weatherStory` context on report-specific briefing `area_forecast_discussion`, and `weather_story`
objects when `/weatherstories/latest` is available - `module.Output` values for derived stanzas:
- optional JSON file written by `briefing.Save` `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning`
The registry also contains accepted composition entries for modules that do not
emit stanzas until a builder exists. App orchestration skips those entries when
constructing snapshots.
## Boundaries ## Boundaries
- This package selects and shapes weather facts for prompts. - This package selects and shapes already-collected weather facts for prompts.
- It does not fetch weather data, compare prior snapshots, build - It validates module composition against report compatibility and option
`data_package` files, invoke Scriptorium, or write workflow metadata. types.
- It does not fetch weather data, compare prior snapshots, write module
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata.
## Config Fields Used ## Config Fields Used
The package receives configured units and timezone from the app layer. Daypart The app layer passes effective units, timezone, and location context into the
configuration is consumed by `internal/forecast` before briefing builders run. module context. `internal/facts` consumes daypart configuration before module
Configured `location` values are prompt context only; Weather API builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance. `sourceLocationId` and `sourceLocation` remain source provenance.
Current conditions are copied from the normalized `/conditions/current` bundle
source only; observation station and timestamp fields remain provenance. `area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields.
## External Adapters Used ## External Adapters Used
@@ -49,39 +62,40 @@ None directly.
## State Or Manifest Behavior ## State Or Manifest Behavior
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is None. `internal/app` collects module outputs into a `module.Snapshot`, and
owned by `internal/state`. `internal/state` persists that snapshot.
## Skip And Resume Behavior ## Skip And Resume Behavior
None. Builders either return a complete briefing package or an error. None. Builders either emit a module output, omit optional unavailable data, or
return an error for invalid required inputs.
## Failure Behavior ## Failure Behavior
- Daily briefing construction requires a Daily report definition and derived - Required derived modules return errors when their dependent facts are not
daily summary. available.
- 3-Day briefing construction requires a 3-Day report definition and at least - Module registry construction rejects duplicate module IDs and duplicate
one derived summary. stanza names.
- Weekend briefing construction requires a Weekend report definition and at - Composition validation rejects unknown modules, duplicate modules,
least one derived summary. incompatible report/module combinations, duplicate stanza names, and invalid
- Storm briefing construction requires a Storm Report definition and forecast option shapes.
bundle. - Source-oriented module builders omit missing optional current conditions,
- Save failures include path and operation context. forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert
source data.
## Tests ## Tests
Inspect: Inspect:
- `internal/briefing/daily_test.go` - `internal/briefing/base_modules_test.go`
- `internal/briefing/three_day_test.go` - `internal/briefing/derived_modules_test.go`
- `internal/briefing/weekend_test.go` - `internal/briefing/modules_test.go`
- `internal/briefing/storm_test.go`
- `internal/app/app_test.go` - `internal/app/app_test.go`
## Invariants ## Invariants
- Briefings contain structured weather facts and source context. - Module outputs contain structured weather facts and source context.
- Common metadata includes RunID, report ID, prompt ID, valid period, source - Common metadata includes RunID, report ID, prompt ID, valid period, source
provenance, source hashes, source warnings, and configured prompt location. provenance, source hashes, source warnings, and configured prompt location.
- LLM prompt input packaging and Scriptorium execution remain outside this - Prompt input packaging and Scriptorium execution remain outside this package.
boundary.

View File

@@ -4,15 +4,15 @@ This document describes structured Recent Changes comparison.
## Purpose ## Purpose
`internal/changes` compares current and prior briefing packages and emits `internal/changes` compares current and prior module snapshots and emits
compact change records for prompt input data packages. compact change records for prompt input data packages.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- prior briefing package - prior module snapshot
- current briefing package - current module snapshot
- comparison thresholds from configuration - comparison thresholds from configuration
Outputs: Outputs:
@@ -22,7 +22,7 @@ Outputs:
## Boundaries ## Boundaries
- This package compares structured briefing data only. - This package compares structured module snapshot data only.
- It does not read filesystem state, find prior snapshots, render Markdown, - It does not read filesystem state, find prior snapshots, render Markdown,
invoke Scriptorium, or compare generated report text. invoke Scriptorium, or compare generated report text.
@@ -41,7 +41,7 @@ None.
## State Or Manifest Behavior ## State Or Manifest Behavior
None directly. The app loads prior briefing snapshots through `internal/state` None directly. The app loads prior module snapshots through `internal/state`
before calling comparison functions. before calling comparison functions.
## Skip And Resume Behavior ## Skip And Resume Behavior
@@ -51,11 +51,12 @@ empty Recent Changes list without calling a comparison function.
## Failure Behavior ## Failure Behavior
- Daily comparison requires both inputs to contain Daily briefing content. - Daily comparison requires `derived_daily_summary` and
- 3-Day comparison requires both inputs to contain 3-Day briefing content. `derived_daypart_summaries` stanzas. It also uses `alert_digest` and
- Weekend comparison requires both inputs to contain Weekend briefing content. `precip_timing` when present.
- Storm Report currently has no comparison implementation, so the app leaves - 3-Day comparison requires `derived_daypart_summaries`.
Recent Changes empty for Storm reports. - Weekend comparison requires `derived_daypart_summaries`.
- Storm Report comparison returns no changes.
## Tests ## Tests

72
docs/internal/facts.md Normal file
View File

@@ -0,0 +1,72 @@
# Fact Contracts Internals
This document describes the fact contract boundary.
## Purpose
`internal/facts` separates normalized upstream facts collected for a report run
from conservative report-scoped facts derived from them. The package gives app
orchestration one place to build reusable facts before module execution.
## Inputs And Outputs
Inputs:
- `weatherdata.Bundle` from the Weather API adapter
- resolved report definition and valid period
- report timezone
- configured daypart definitions
Outputs:
- `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
daily summaries, daypart summaries, and Storm Report window summary
## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a
report run.
- It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules.
## Config Fields Used
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
- `weather_api.timezone`
## External Adapters Used
None directly. Collected facts are built from `weatherdata.Bundle`.
## State Or Manifest Behavior
None. Source provenance and warnings remain data fields for downstream metadata
and inspection.
## Failure Behavior
- Invalid or missing report valid periods return an error.
- Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
## Tests
Inspect:
- `internal/facts/facts_test.go`
- `internal/app/app_test.go`
## Invariants
- Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report.
- Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.

View File

@@ -5,14 +5,14 @@ This document describes deterministic forecast summarization in
## Purpose ## Purpose
`internal/forecast` converts normalized bundle data into daily and period `internal/forecast` converts normalized weather data into daily and period
summaries used by briefing builders. summaries used by fact builders and module builders.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- `forecast.Bundle` - `weatherdata.Bundle`
- local date or resolved report period - local date or resolved report period
- timezone - timezone
- configured daypart definitions - configured daypart definitions
@@ -43,12 +43,12 @@ configuration.
## External Adapters Used ## External Adapters Used
None directly. Forecast data arrives through `forecast.Bundle`. None directly. Forecast data arrives through `weatherdata.Bundle`.
## State Or Manifest Behavior ## State Or Manifest Behavior
None. Source warnings and provenance from the bundle are carried into summaries None. Source warnings and provenance from the bundle are carried into summaries
for later metadata and briefing output. for later metadata and module output.
## Skip And Resume Behavior ## Skip And Resume Behavior

102
docs/internal/module.md Normal file
View File

@@ -0,0 +1,102 @@
# Module Contract Internals
This document describes the module contract in `internal/module`.
## Purpose
`internal/module` defines the shared identifiers and data envelopes used for
prompt-facing modules. Report definitions use module IDs for composition,
module builders produce outputs with stanza names, prompt input packages consume
snapshots, and Recent Changes compares snapshot stanzas.
## Inputs And Outputs
Inputs:
- ordered `module.ConfigItem` values from report definitions or config
overrides
- `module.Output` values produced by module builders
Outputs:
- stable `module.ID` constants
- typed option structs for registered modules
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
- ordered snapshot outputs with module ID, stanza name, and typed value
- typed stanza lookup through `module.StanzaValue`
## Registered Module IDs
The registry recognizes these IDs:
- `metadata`
- `current_conditions`
- `derived_daily_summary`
- `derived_daypart_summaries`
- `hourly_table`
- `precip_timing`
- `alert_digest`
- `area_forecast_discussion`
- `weather_story`
- `forecast_delta`
- `outdoor_windows`
- `tomorrow_planning`
- `weekend_planning`
- `storm_window_summary`
Modules with builders emit stanzas into module snapshots. Registered modules
without builders are valid composition entries but do not emit snapshot stanzas.
That keeps report composition declarations centralized while limiting prompt
packages to data the application builds.
## Options
Most modules use an empty options struct. `area_forecast_discussion` accepts:
```yaml
sections:
- product
- key_messages
- short_term
- long_term
```
An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition
validation.
## Boundaries
- This package owns module identifiers, config item envelopes, output
envelopes, snapshot validation, and typed stanza lookup.
- It does not define report IDs, execute builders, fetch weather data, derive
forecast facts, write state, or invoke Scriptorium.
## State Or Manifest Behavior
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
validation rejects missing schema version, missing module IDs, missing stanza
names, duplicate module outputs, and duplicate stanza names while preserving
output order.
## Failure Behavior
- Snapshot construction fails for duplicate module outputs or duplicate stanza
names.
- Typed stanza lookup returns `found=false` for missing stanzas.
- Typed stanza lookup wraps JSON marshal/decode failures with stanza context.
## Tests
Inspect:
- `internal/module/module_test.go`
- `internal/briefing/modules_test.go`
- `internal/report/period_test.go`
## Invariants
- `internal/module` does not import `internal/report`.
- Module IDs are stable strings.
- Each emitted module output has exactly one stanza name and one typed value.
- Snapshot output order is caller-owned and preserved.

View File

@@ -1,40 +1,64 @@
# Prompt Input Internals # Prompt Input Internals
This document describes prompt input data package construction. This document describes YAML prompt data package construction in
`internal/promptinput`.
## Purpose ## Purpose
`internal/promptinput` converts a structured briefing package and optional `internal/promptinput` converts report metadata, an ordered module snapshot,
Recent Changes into the `data_package` JSON passed to Scriptorium prompts. Recent Changes, and source warnings into the `data_package` file passed to
Scriptorium.
The persisted data package is YAML with schema version
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
used for inspection and comparison.
## Inputs And Outputs ## Inputs And Outputs
Inputs: Inputs:
- `briefing.Package` - report metadata from app/state orchestration
- `module.Snapshot`
- optional `[]changes.Change` - optional `[]changes.Change`
Outputs: Outputs:
- `promptinput.Package` containing schema version, RunID, report metadata, - `promptinput.Package` with schema version, RunID, report metadata, named
briefing content, Recent Changes, and source warnings. Briefing content module stanzas, Recent Changes, and source warnings
includes configured location context, current conditions when available, - YAML bytes from `promptinput.MarshalYAML`
structured weather story context when available, discussion key messages, and - YAML file written atomically by `promptinput.Save`
short/long-term AFD narratives when the Weather API provides them.
- report metadata includes `currentLocalDate`, the generation date formatted as The YAML shape includes:
`YYYY-MM-DD` in the effective report timezone.
- optional JSON file written by `promptinput.Save` ```yaml
schema_version: weatherreporter.data_package.v2
run_id: <run_id>
report:
id: <report_id>
prompt_id: <prompt_id>
briefing:
metadata: {}
current_conditions: {}
recent_changes:
items: []
```
The `briefing` mapping contains named module stanzas. Stanza order follows the
module snapshot output order.
## Boundaries ## Boundaries
- This package owns the prompt input schema and validation. - This package owns prompt package schema, YAML marshaling, YAML loading, and
- It does not fetch weather data, derive forecast summaries, find prior validation.
snapshots, compare changes, or invoke Scriptorium. - It does not fetch weather data, derive forecast summaries, execute modules,
find prior snapshots, compare changes, choose artifact paths, or invoke
Scriptorium.
## Config Fields Used ## Config Fields Used
None directly. Config-derived values, including timezone and prompt location None directly. Config-derived values such as timezone, units, and prompt
context, are already present in briefing metadata before this package runs. location are already present in report metadata and module stanzas before this
package runs.
## External Adapters Used ## External Adapters Used
@@ -42,7 +66,7 @@ None.
## State Or Manifest Behavior ## State Or Manifest Behavior
`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by `promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
`internal/state`. `internal/state`.
## Skip And Resume Behavior ## Skip And Resume Behavior
@@ -51,9 +75,10 @@ None. Recent Changes is always present as an `items` list and may be empty.
## Failure Behavior ## Failure Behavior
Validation fails before render preflight when required top-level or briefing Validation fails before render preflight when required top-level fields are
metadata fields are missing or inconsistent, or when no report content is missing or inconsistent, when the valid period is invalid, or when no module
present. Save failures include filesystem operation and path context. stanzas are present. Save failures include filesystem operation and path
context.
## Tests ## Tests
@@ -64,6 +89,7 @@ Inspect:
## Invariants ## Invariants
- Scriptorium receives structured `data_package` JSON. - Scriptorium receives structured YAML through `--input data_package=<path>`.
- Briefing metadata and top-level report metadata must agree. - Module stanza order is deterministic for generated snapshots.
- Recent Changes are not inferred from rendered report text. - Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text.

View File

@@ -7,8 +7,8 @@ membership, output naming, artifact grouping, and comparison declarations in
## Purpose ## Purpose
`internal/report` is the canonical source for report definitions. App, state, `internal/report` is the canonical source for report definitions. App, state,
briefing, and CLI wiring consume resolved definitions instead of owning report module building, and CLI wiring consume resolved definitions instead of owning
identity policy themselves. report identity policy themselves.
## Definition Fields ## Definition Fields
@@ -23,8 +23,9 @@ Each report definition declares:
- generated-report eligibility - generated-report eligibility
- prior-report compatibility list - prior-report compatibility list
- morning or evening batch membership - morning or evening batch membership
- default ordered module composition
## Implemented Reports ## Reports
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility | | Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
@@ -34,7 +35,7 @@ Each report definition declares:
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook | | Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report | | Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
All implemented report definitions are eligible for generation. All report definitions are eligible for generation.
## Valid Periods ## Valid Periods
@@ -54,8 +55,8 @@ must be after start time.
## Boundaries ## Boundaries
`internal/report` defines report metadata and time coverage. It does not fetch `internal/report` defines report metadata and time coverage. It does not fetch
weather data, build briefings, compare briefing contents, write state, parse CLI weather data, build module values, compare snapshot contents, write state,
flags, or invoke Scriptorium. parse CLI flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report The CLI owns public command names. The app maps those command names to report
IDs, then uses the registry for report policy. IDs, then uses the registry for report policy.
@@ -70,6 +71,7 @@ output path copying uses batch output names from report definitions.
- State paths use `ArtifactGroup`. - State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`. - Batch output copies use `BatchOutputName`.
- Generation checks `Generated`. - Generation checks `Generated`.
- Module composition defaults use `Modules`.
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy. - Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
- RunIDs include the resolved report ID. - RunIDs include the resolved report ID.
@@ -93,5 +95,5 @@ Inspect:
- Daily Today and Daily Tomorrow both use `weather.daily_report`. - Daily Today and Daily Tomorrow both use `weather.daily_report`.
- Valid periods are half-open intervals independent of rendered report text. - Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility, - Artifact grouping, batch output filenames, generated-report eligibility,
comparison compatibility, and comparison strategy are declared by report default module composition, comparison compatibility, and comparison strategy
definition. are declared by report definition.

View File

@@ -15,7 +15,7 @@ from app and domain packages.
Inputs: Inputs:
- prompt ID - prompt ID
- prompt input data package path - YAML prompt input data package path
- report output path for `run` - report output path for `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
@@ -96,5 +96,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`.
- Render and run preserve command-specific result structs. - Render and run preserve command-specific result structs.
- Scriptorium-specific flags stay inside adapter and config boundaries. - Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -13,7 +13,7 @@ Inputs:
- workspace configuration - workspace configuration
- resolved report definition and valid period - resolved report definition and valid period
- briefing package - module snapshot
- prompt input data package - prompt input data package
- preflight artifact - preflight artifact
- rendered report path preparation request - rendered report path preparation request
@@ -21,20 +21,20 @@ Inputs:
Outputs: Outputs:
- briefing snapshot JSON path - module snapshot JSON path
- prompt input data package JSON path - prompt input data package YAML path
- render preflight JSON path - render preflight 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 briefing or data package - loaded module snapshot or data package
- recent report records for inspection - recent report records for inspection
## Boundaries ## Boundaries
`internal/state` owns local filesystem layout, path validation, durable writes, `internal/state` owns local filesystem layout, path validation, durable writes,
metadata reads, prior lookup, and report listing. It does not fetch weather metadata reads, prior lookup, and report listing. It does not fetch weather
data, derive forecasts, build prompt input content, compare briefing contents, data, derive forecasts, build prompt input content, compare module contents,
invoke Scriptorium, import adapter result types, or parse CLI flags. invoke Scriptorium, import adapter result types, or parse CLI flags.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
@@ -55,19 +55,19 @@ Workspace subdirectories must be relative paths that stay under
## Managed Layout ## Managed Layout
Paths are derived from the resolved report definition's artifact group, the Paths are derived from the resolved report definition's artifact group, the
valid-period start date for JSON artifacts, and the RunID. valid-period start date for dated artifacts, and the RunID.
```text ```text
<workspace.root>/ <workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.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
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json 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
reports/<artifact_group>/<run_id>.md reports/<artifact_group>/<run_id>.md
``` ```
Metadata is stored beside briefing snapshots and links the briefing, 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. Report listing walks metadata files under the
snapshots directory. snapshots directory.
@@ -84,8 +84,8 @@ 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.
- Storm Report currently has no prior lookup because explicit event-window - Storm Report has no prior lookup because explicit event-window comparison is
comparison is not searched by the filesystem store. not searched by the filesystem store.
## Writes And Inspection ## Writes And Inspection
@@ -97,9 +97,9 @@ notification is attempted and include rendered distributor pipeline ID, bundle
ID, idempotency key, bundle paths, upload status, latest run status, and ID, idempotency key, bundle paths, upload status, latest run status, and
redacted errors. redacted errors.
Inspection helpers read existing metadata, briefing, and data package files. Inspection helpers read existing metadata, module snapshot, and data package
Missing metadata directories return no inspection records or no prior snapshot files. Missing metadata directories return no inspection records or no prior
rather than creating state. snapshot rather than creating state.
## Failure Behavior ## Failure Behavior

View File

@@ -1,12 +1,12 @@
# Weather Data Internals # Weather Data Internals
This document describes Weather API ingestion into `forecast.Bundle`. This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose ## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one `internal/adapters/weatherapi` fetches normalized weather data from one
configured Weather API endpoint and assembles the bundle consumed by forecast configured Weather API endpoint and assembles the bundle consumed by forecast
derivation and briefing builders. Briefing builders expose normalized current derivation and module builders. Module builders expose normalized current
conditions and weather story context when those sources are available. conditions and weather story context when those sources are available.
## Inputs And Outputs ## Inputs And Outputs
@@ -19,7 +19,7 @@ Inputs:
Outputs: Outputs:
- `forecast.Bundle` with observation, current conditions, hourly forecast, - `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings records, and source warnings
- stub source record for the daily forecast source slot - stub source record for the daily forecast source slot
@@ -29,7 +29,7 @@ Outputs:
- The adapter owns HTTP calls, response-envelope handling, source hashing, and - The adapter owns HTTP calls, response-envelope handling, source hashing, and
decoding into internal bundle types. decoding into internal bundle types.
- It does not derive dayparts, resolve report periods, build briefings, compare - It does not derive dayparts, resolve report periods, build module values, compare
snapshots, write report state, or invoke Scriptorium. snapshots, write report state, or invoke Scriptorium.
## Config Fields Used ## Config Fields Used

View File

@@ -1,12 +1,12 @@
# Weatherreporter Operations # Weatherreporter Operations
This guide covers normal operation, generated artifacts, inspection, recovery, This guide covers normal operation, generated artifacts, inspection, recovery,
and current operational caveats. For symptom-specific diagnosis, see and operational caveats. For symptom-specific diagnosis, see
[Troubleshooting](troubleshooting.md). [Troubleshooting](troubleshooting.md).
## Normal Workflow ## Normal Workflow
Implemented generation commands: Generation commands:
```text ```text
weatherreporter generate daily --date 2026-05-29 weatherreporter generate daily --date 2026-05-29
@@ -17,14 +17,15 @@ 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 Each command resolves a report period, fetches a Weather API bundle, builds a
briefing, builds a prompt input data package, runs `scriptorium render`, runs JSON module snapshot, builds a YAML prompt input data package, runs
`scriptorium run`, and writes managed artifacts under the configured workspace. `scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
the configured workspace.
When distributor notification is enabled, weatherreporter uploads the managed When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after `scriptorium run` succeeds and final metadata is saved. Markdown report after `scriptorium run` succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for the current generated report; it `--out PATH` writes an extra Markdown copy for the current generated report; it
is not used as the distributor upload source. is not used as the distributor upload source.
Implemented batch commands: Batch commands:
```text ```text
weatherreporter run morning weatherreporter run morning
@@ -51,33 +52,33 @@ workspace/
snapshots/ snapshots/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.briefing.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
three-day/ three-day/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.briefing.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.briefing.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.briefing.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
data-packages/ data-packages/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
three-day/ three-day/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.yaml
preflight/ preflight/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
@@ -131,7 +132,7 @@ Each generated report writes metadata that links:
- RunID, report ID, variant, and prompt ID - RunID, report ID, variant, and prompt ID
- generation time, timezone, and valid period - generation time, timezone, and valid period
- source location, source hashes, and source warnings - source location, source hashes, and source warnings
- briefing snapshot path - module snapshot path
- prompt input data package path - prompt input data package path
- preflight output path - preflight output path
- managed Markdown report path - managed Markdown report path
@@ -164,11 +165,11 @@ 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, briefing, Notification happens after final metadata save. Weather API, module snapshot,
data-package, render preflight, Scriptorium run, and metadata-save failures do data-package, render preflight, Scriptorium run, and metadata-save failures do
not trigger notification. A notification failure fails that report. In a batch, not trigger notification. A notification failure fails that report.
other reports continue, the failed report includes notification fields in the In a batch, other reports continue, the failed report includes notification
JSON summary, and the batch returns nonzero. fields in the JSON summary, and the batch returns nonzero.
Each notification attempt writes a debug artifact under `notifications/`. The Each notification attempt writes a debug artifact under `notifications/`. The
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed artifact records the rendered pipeline ID, bundle ID, idempotency key, managed
@@ -185,7 +186,7 @@ constructing a source bundle, and submitting it to the configured distributor
HTTP endpoint. Distributor remains responsible for destination routing, HTTP endpoint. Distributor remains responsible for destination routing,
publication, and any downstream Markdown-to-HTML transformation. Distributor publication, and any downstream Markdown-to-HTML transformation. Distributor
leaves destination files alone when they are not tracked by a newly uploaded leaves destination files alone when they are not tracked by a newly uploaded
bundle, so previously uploaded dated report paths can remain available. bundle, so existing uploaded dated report paths can remain available.
## Inspection ## Inspection
@@ -195,30 +196,30 @@ They do not fetch weather data or run `scriptorium`.
```text ```text
weatherreporter inspect reports --limit 10 weatherreporter inspect reports --limit 10
weatherreporter inspect metadata RUN_ID weatherreporter inspect metadata RUN_ID
weatherreporter inspect briefing RUN_ID weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID weatherreporter inspect sources RUN_ID
``` ```
Use `inspect reports` to find recent RunIDs and artifact paths. Use Use `inspect reports` to find RunIDs and artifact paths. Use
`inspect metadata` to see the artifact links recorded for a run. Use `inspect metadata` to see the artifact links recorded for a run. Use
`inspect briefing` and `inspect data-package` to review the exact structured `inspect modules` to review the persisted ordered module snapshot, and
inputs used for rendering. Use `inspect prior` to see the prior comparable `inspect data-package` to review the structured prompt package used for
snapshot selected for Recent Changes, or `null` when none exists. Use rendering. Use `inspect prior` to see the prior comparable snapshot selected for
`inspect sources` to review source provenance and warnings without dumping full Recent Changes, or `null` when none exists. Use `inspect sources` to review
weather payloads. source provenance and warnings without dumping full weather payloads.
## Recent Changes ## Recent Changes
Recent Changes are computed from structured briefing snapshots, not rendered Recent Changes are computed from structured module snapshots, not rendered
Markdown text. 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. Storm Report currently compatible Weekend snapshots for the same weekend window. Storm Report leaves
leaves Recent Changes empty. Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed, When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty. `recentChanges.items` is empty.
@@ -241,7 +242,7 @@ For a bad report, start with:
```text ```text
weatherreporter inspect metadata RUN_ID weatherreporter inspect metadata RUN_ID
weatherreporter inspect sources RUN_ID weatherreporter inspect sources RUN_ID
weatherreporter inspect briefing RUN_ID weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID weatherreporter inspect prior RUN_ID
``` ```

View File

@@ -3,13 +3,13 @@
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the projects shape, boundaries, and invariants as the code evolves. This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the projects shape, boundaries, and invariants as the code evolves.
## weatherreporter ## weatherreporter
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific briefing packages, compares those packages against prior snapshots, and invokes an external prompt runner to produce human-facing reports. `weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical. The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated module-based prompt packages rather than raw unbounded source payloads wherever practical.
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, briefing builder, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code. Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, module composition, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, briefing snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text. Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages. `scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.

View File

@@ -16,11 +16,14 @@ Developers and LLM coding agents should use it with
- `internal/adapters/distributor`: Distributor upload adapter. - `internal/adapters/distributor`: Distributor upload adapter.
- `internal/adapters/weatherapi`: Weather API HTTP adapter. - `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter. - `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/forecast`: normalized bundle types and deterministic forecast - `internal/weatherdata`: normalized weather source facts, source metadata, and
derivation. source warnings.
- `internal/forecast`: deterministic forecast derivation.
- `internal/facts`: collected and derived report fact contracts.
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
- `internal/report`: report definitions, valid periods, batches, output names, - `internal/report`: report definitions, valid periods, batches, output names,
and comparison declarations. and comparison declarations.
- `internal/briefing`: report-specific briefing package builders. - `internal/briefing`: prompt-facing module value builders and module registry.
- `internal/changes`: structured Recent Changes comparison. - `internal/changes`: structured Recent Changes comparison.
- `internal/promptinput`: Scriptorium `data_package` construction and - `internal/promptinput`: Scriptorium `data_package` construction and
validation. validation.

View File

@@ -195,7 +195,7 @@ Diagnostic: inspect stdout for the JSON summary and stderr for compact status
lines. lines.
Safe fix: use the failed report's artifact paths from the summary, then inspect Safe fix: use the failed report's artifact paths from the summary, then inspect
metadata, sources, briefing, and data package for that RunID. metadata, sources, module snapshot, and data package for that RunID.
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md). Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).

View File

@@ -66,3 +66,23 @@ recent_change:
precip_probability_points: 20 precip_probability_points: 20
wind_gust_miles_per_hour: 10 wind_gust_miles_per_hour: 10
precip_timing_shift_minutes: 120 precip_timing_shift_minutes: 120
reports:
daily:
deterministic_modules:
- metadata
- current_conditions
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- forecast_delta
- id: area_forecast_discussion
options:
sections:
- product
- key_messages
- short_term
- long_term
- weather_story
- outdoor_windows

View File

@@ -20,7 +20,7 @@ func TestRenderConstructsCommand(t *testing.T) {
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err != nil { if err != nil {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -31,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json", "--input", "data_package=/tmp/data_package.yaml",
"--format", "json", "--format", "json",
} }
if commands.name != "/usr/local/bin/scriptorium" { if commands.name != "/usr/local/bin/scriptorium" {
@@ -57,7 +57,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err == nil { if err == nil {
t.Fatal("Render() error = nil, want nonzero exit error") t.Fatal("Render() error = nil, want nonzero exit error")
@@ -85,7 +85,7 @@ func TestRunConstructsCommand(t *testing.T) {
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
if err != nil { if err != nil {
@@ -97,7 +97,7 @@ func TestRunConstructsCommand(t *testing.T) {
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json", "--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md", "--out", "/tmp/daily.md",
} }
if commands.name != "/usr/local/bin/scriptorium" { if commands.name != "/usr/local/bin/scriptorium" {
@@ -130,7 +130,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
if err == nil { if err == nil {

View File

@@ -1,4 +1,4 @@
// Package weatherapi adapts the internal weather API to forecast bundles. // Package weatherapi adapts the internal weather API to weather data bundles.
package weatherapi package weatherapi
import ( import (
@@ -18,7 +18,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"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/weatherdata"
) )
type Client struct { type Client struct {
@@ -83,11 +83,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
return client, nil return client, nil
} }
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) { func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
fetchedAt := c.now() fetchedAt := c.now()
builder := bundleBuilder{ builder := bundleBuilder{
client: c, client: c,
bundle: &forecast.Bundle{FetchedAt: fetchedAt}, bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt, fetchedAt: fetchedAt,
} }
@@ -121,7 +121,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
type bundleBuilder struct { type bundleBuilder struct {
client *Client client *Client
bundle *forecast.Bundle bundle *weatherdata.Bundle
fetchedAt time.Time fetchedAt time.Time
} }
@@ -133,7 +133,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "observation data is missing", false) return b.handleMissing(&source, "observation data is missing", false)
} }
var observation forecast.Observation var observation weatherdata.Observation
if err := decodeSource(raw, &observation); err != nil { if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -151,7 +151,7 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false) return b.handleMissing(&source, "current conditions data is missing", false)
} }
var current forecast.Current var current weatherdata.Current
if err := decodeSource(raw, &current); err != nil { if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -168,7 +168,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "hourly forecast data is missing", true) return b.handleMissing(&source, "hourly forecast data is missing", true)
} }
var hourly forecast.ForecastRun var hourly weatherdata.ForecastRun
if err := decodeSource(raw, &hourly); err != nil { if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err) return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
} }
@@ -190,7 +190,7 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "narrative forecast data is missing", false) return b.handleMissing(&source, "narrative forecast data is missing", false)
} }
var narrative forecast.ForecastRun var narrative weatherdata.ForecastRun
if err := decodeSource(raw, &narrative); err != nil { if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -210,11 +210,11 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
return b.handleMissing(&source, "active alerts data is missing", false) return b.handleMissing(&source, "active alerts data is missing", false)
} }
if isJSONNull(raw) { if isJSONNull(raw) {
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)} b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.addSource(source) b.addSource(source)
return nil return nil
} }
var alerts forecast.AlertRun var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil { if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -235,7 +235,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "forecast discussion data is missing", false) return b.handleMissing(&source, "forecast discussion data is missing", false)
} }
var discussion forecast.Discussion var discussion weatherdata.Discussion
if err := decodeSource(raw, &discussion); err != nil { if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -254,7 +254,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
if raw == nil { if raw == nil {
return b.handleMissing(&source, "NWS weather story data is missing", false) return b.handleMissing(&source, "NWS weather story data is missing", false)
} }
var story forecast.WeatherStory var story weatherdata.WeatherStory
if err := decodeSource(raw, &story); err != nil { if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
} }
@@ -268,7 +268,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
} }
func (b *bundleBuilder) addStub(sourceName string, message string) error { func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := forecast.Source{ source := weatherdata.Source{
Name: sourceName, Name: sourceName,
FetchedAt: b.fetchedAt, FetchedAt: b.fetchedAt,
Missing: true, Missing: true,
@@ -276,7 +276,7 @@ func (b *bundleBuilder) addStub(sourceName string, message string) error {
return b.applyMissingPolicy(&source, "missing_source", message) return b.applyMissingPolicy(&source, "missing_source", message)
} }
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error { func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true source.Missing = true
if required { if required {
return fmt.Errorf("%s from %s is required", message, source.Endpoint) return fmt.Errorf("%s from %s is required", message, source.Endpoint)
@@ -284,7 +284,7 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r
return b.applyMissingPolicy(source, "missing_source", message) return b.applyMissingPolicy(source, "missing_source", message)
} }
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error { func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
if required { if required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err) return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
} }
@@ -292,13 +292,13 @@ func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, requ
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err)) return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
} }
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error { func (b *bundleBuilder) applyMissingPolicy(source *weatherdata.Source, code string, message string) error {
policy := b.client.policyFor(source.Name) policy := b.client.policyFor(source.Name)
if policy == config.MissingSourceError { if policy == config.MissingSourceError {
return fmt.Errorf("%s: %s", source.Name, message) return fmt.Errorf("%s: %s", source.Name, message)
} }
if policy == config.MissingSourceWarn { if policy == config.MissingSourceWarn {
warning := forecast.SourceWarning{ warning := weatherdata.SourceWarning{
Source: source.Name, Source: source.Name,
Code: code, Code: code,
Severity: "warning", Severity: "warning",
@@ -313,7 +313,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
return nil return nil
} }
func (b *bundleBuilder) addSource(source forecast.Source) { func (b *bundleBuilder) addSource(source weatherdata.Source) {
b.bundle.Sources = append(b.bundle.Sources, source) b.bundle.Sources = append(b.bundle.Sources, source)
} }
@@ -335,33 +335,33 @@ type envelope struct {
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
} }
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) { func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
reqURL := c.endpointURL(endpoint, opts) reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil { if err != nil {
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err) return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
} }
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err) return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil { if err != nil {
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err) return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
} }
var env envelope var env envelope
if err := json.Unmarshal(body, &env); err != nil { if err := json.Unmarshal(body, &env); err != nil {
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err) return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
} }
source := forecast.Source{ source := weatherdata.Source{
Name: sourceName, Name: sourceName,
Endpoint: endpoint, Endpoint: endpoint,
Query: queryMap(reqURL.Query()), Query: queryMap(reqURL.Query()),
@@ -430,7 +430,7 @@ func sourceHash(raw json.RawMessage) (string, error) {
return hex.EncodeToString(sum[:]), nil return hex.EncodeToString(sum[:]), nil
} }
func SaveBundle(path string, bundle *forecast.Bundle) error { func SaveBundle(path string, bundle *weatherdata.Bundle) error {
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil { if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
return fmt.Errorf("save bundle: %w", err) return fmt.Errorf("save bundle: %w", err)
} }

View File

@@ -12,7 +12,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestFetchBundleFromFixtures(t *testing.T) { func TestFetchBundleFromFixtures(t *testing.T) {
@@ -423,7 +423,7 @@ func containsPath(requested []string, path string) bool {
return false return false
} }
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source { func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
t.Helper() t.Helper()
for _, source := range sources { for _, source := range sources {
if source.Name == name { if source.Name == name {
@@ -431,7 +431,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast
} }
} }
t.Fatalf("source %q not found in %#v", name, sources) t.Fatalf("source %q not found in %#v", name, sources)
return forecast.Source{} return weatherdata.Source{}
} }
func hashFixtureData(t *testing.T, fixture string) string { func hashFixtureData(t *testing.T, fixture string) string {

View File

@@ -14,12 +14,15 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"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/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/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"
) )
type ReportKind string type ReportKind string
@@ -65,10 +68,14 @@ type FetchBundleRequest struct {
OutputPath string OutputPath string
} }
type BriefingRequest struct { type ModuleSnapshotRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
OutputPath string }
type ReportFacts struct {
Collected facts.CollectedFacts
Derived facts.DerivedFacts
} }
type ReportRequest struct { type ReportRequest struct {
@@ -80,14 +87,9 @@ type ReportRequest struct {
Notifier Notifier Notifier Notifier
} }
type BriefingResult struct {
Package briefing.Package
OutputPath string
}
type ReportResult struct { type ReportResult struct {
Briefing briefing.Package ModuleSnapshot module.Snapshot
BriefingPath string ModuleSnapshotPath string
DataPackage promptinput.Package DataPackage promptinput.Package
DataPackagePath string DataPackagePath string
PreflightPath string PreflightPath string
@@ -127,7 +129,6 @@ type BatchReportResult struct {
NotificationPath string `json:"notificationPath,omitempty"` NotificationPath string `json:"notificationPath,omitempty"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod"`
BriefingPath string `json:"briefingPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"` DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"` PreflightPath string `json:"preflightPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"` ReportPath string `json:"reportPath,omitempty"`
@@ -260,7 +261,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
for _, resolved := range resolvedReports { for _, resolved := range resolvedReports {
item := batchReportResult(resolved) item := batchReportResult(resolved)
if paths, err := store.Paths(resolved); err == nil { if paths, err := store.Paths(resolved); err == nil {
item.BriefingPath = paths.Briefing
item.DataPackagePath = paths.DataPackage item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport item.ReportPath = paths.RenderedReport
@@ -290,7 +290,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
result.Failed++ result.Failed++
} else { } else {
item.Status = "succeeded" item.Status = "succeeded"
item.BriefingPath = reportResult.BriefingPath
item.DataPackagePath = reportResult.DataPackagePath item.DataPackagePath = reportResult.DataPackagePath
item.PreflightPath = reportResult.PreflightPath item.PreflightPath = reportResult.PreflightPath
item.ReportPath = reportResult.ReportPath item.ReportPath = reportResult.ReportPath
@@ -341,7 +340,11 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
if err != nil { if err != nil {
return report.Resolved{}, err return report.Resolved{}, err
} }
return report.DefaultRegistry().Resolve(id, report.ResolveRequest{ registry, err := reportRegistry(req.Config)
if err != nil {
return report.Resolved{}, err
}
return registry.Resolve(id, report.ResolveRequest{
Now: now, Now: now,
Location: location, Location: location,
Date: req.Date, Date: req.Date,
@@ -359,12 +362,24 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{ registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
return registry.BatchReports(batch, report.ResolveRequest{
Now: now, Now: now,
Location: location, Location: location,
}) })
} }
func reportRegistry(cfg config.Config) (report.Registry, error) {
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides())
if err != nil {
return report.Registry{}, err
}
return registry, nil
}
func reportIDForCommand(kind ReportKind) (report.ID, error) { func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind { switch kind {
case ReportDaily: case ReportDaily:
@@ -393,7 +408,7 @@ func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
} }
} }
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
client, err := weatherapi.New(req.Config) client, err := weatherapi.New(req.Config)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -405,7 +420,7 @@ func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle,
return bundle, nil return bundle, nil
} }
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
if req.OutputPath == "" { if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required") return nil, fmt.Errorf("output path is required")
} }
@@ -419,33 +434,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
return bundle, nil return bundle, nil
} }
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
pkg, err := BuildBriefing(req, bundle)
if err != nil {
return nil, err
}
outputPath := req.OutputPath
if outputPath == "" {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
outputPath = paths.Briefing
}
if err := briefing.Save(outputPath, pkg); err != nil {
return nil, err
}
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
store := req.Store store := req.Store
if store == nil { if store == nil {
@@ -468,24 +456,43 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
briefingPackage, err := BuildBriefing(BriefingRequest{ reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
Config: req.Config, Config: req.Config,
Resolved: req.Resolved, Resolved: req.Resolved,
}, bundle) }, bundle)
if err != nil { if err != nil {
return nil, err return nil, err
} }
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{
briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage) Config: req.Config,
Resolved: req.Resolved,
}, reportFacts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange) moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges)
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
if err != nil {
return nil, err
}
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
Modules: moduleSnapshot,
RecentChanges: recentChanges,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -493,6 +500,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
metadata.DataPackagePath = dataPackagePath
renderer := req.Renderer renderer := req.Renderer
if renderer == nil { if renderer == nil {
@@ -517,13 +525,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, err return nil, err
} }
} }
metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{ metadata.PreflightPath = preflightPath
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: paths.RenderedReport,
})
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata) metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
if metadataErr != nil { if metadataErr != nil {
return nil, metadataErr return nil, metadataErr
@@ -572,8 +574,8 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
} }
return &ReportResult{ return &ReportResult{
Briefing: briefingPackage, ModuleSnapshot: moduleSnapshot,
BriefingPath: briefingPath, ModuleSnapshotPath: moduleSnapshotPath,
DataPackage: dataPackage, DataPackage: dataPackage,
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
PreflightPath: preflightPath, PreflightPath: preflightPath,
@@ -787,64 +789,101 @@ func distributorUploadFiles(sourcePath string, bundlePaths []string) []distribut
return files return files
} }
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { func BuildModuleSnapshot(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (module.Snapshot, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) reportFacts, err := BuildReportFacts(req, bundle)
if err != nil { if err != nil {
return briefing.Package{}, err return module.Snapshot{}, err
} }
dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts)) return BuildModuleSnapshotFromFacts(req, reportFacts)
for _, daypart := range req.Config.Dayparts { }
func BuildReportFacts(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (ReportFacts, error) {
collected := facts.BuildCollected(bundle)
derived, err := buildDerivedFacts(req.Config, req.Resolved, collected)
if err != nil {
return ReportFacts{}, err
}
return ReportFacts{
Collected: collected,
Derived: derived,
}, nil
}
func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportFacts) (module.Snapshot, error) {
if !req.Resolved.ValidPeriod.IsValid() {
return module.Snapshot{}, fmt.Errorf("resolved valid period is required")
}
registry, err := briefing.DefaultModuleRegistry()
if err != nil {
return module.Snapshot{}, err
}
moduleContext := briefing.ModuleContext{
Resolved: req.Resolved,
Collected: reportFacts.Collected,
Derived: reportFacts.Derived,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}
var outputs []module.Output
for _, item := range req.Resolved.Definition.Modules {
definition, err := registry.Lookup(item.ID)
if err != nil {
return module.Snapshot{}, err
}
if definition.Builder == nil {
continue
}
output, err := registry.BuildModule(moduleContext, item)
if err != nil {
return module.Snapshot{}, err
}
if output == nil {
continue
}
outputs = append(outputs, *output)
}
return module.NewSnapshot(outputs)
}
func briefingBuildContext(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) briefing.BuildContext {
return briefing.BuildContext{
Resolved: resolved,
Bundle: collected.Bundle(),
Units: cfg.WeatherAPI.Units,
Timezone: cfg.WeatherAPI.Timezone,
Location: briefingLocation(cfg),
}
}
func promptMetadata(metadata state.Metadata) promptinput.Metadata {
return promptinput.Metadata{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: metadata.Variant,
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod,
SourceWarnings: metadata.SourceWarnings,
}
}
func buildDerivedFacts(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) (facts.DerivedFacts, error) {
dayparts := make([]forecast.DaypartDefinition, 0, len(cfg.Dayparts))
for _, daypart := range cfg.Dayparts {
dayparts = append(dayparts, forecast.DaypartDefinition{ dayparts = append(dayparts, forecast.DaypartDefinition{
Name: daypart.Name, Name: daypart.Name,
Start: daypart.Start, Start: daypart.Start,
End: daypart.End, End: daypart.End,
}) })
} }
switch req.Resolved.Definition.ID { return facts.BuildDerived(facts.BuildDerivedRequest{
case report.DailyToday, report.DailyTomorrow: Resolved: resolved,
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts) Timezone: cfg.WeatherAPI.Timezone,
if err != nil { Dayparts: dayparts,
return briefing.Package{}, err Collected: collected,
}
return briefing.BuildDaily(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summary)
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
if req.Resolved.Definition.ID == report.Weekend {
return briefing.BuildWeekend(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries)
}
return briefing.BuildThreeDay(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries)
case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}) })
default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
}
} }
func briefingLocation(cfg config.Config) *briefing.LocationContext { func briefingLocation(cfg config.Config) *briefing.LocationContext {
@@ -864,11 +903,11 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace) return state.NewFilesystemStore(cfg.Workspace)
} }
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, reportID report.ID, current module.Snapshot, cfg config.RecentChangeConfig) ([]changes.Change, error) {
if priorSnapshot == nil { if priorSnapshot == nil {
return nil, nil return nil, nil
} }
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath) previous, err := store.LoadModuleSnapshot(ctx, priorSnapshot.ModuleSnapshotPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -878,7 +917,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
WindGustMilesPerHour: cfg.WindGustMilesPerHour, WindGustMilesPerHour: cfg.WindGustMilesPerHour,
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
} }
switch current.Metadata.ReportID { switch reportID {
case report.DailyToday, report.DailyTomorrow: case report.DailyToday, report.DailyTomorrow:
return changes.CompareDaily(previous, current, thresholds) return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay: case report.ThreeDay:

View File

@@ -15,9 +15,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func TestFetchAndSaveBundle(t *testing.T) { func TestFetchAndSaveBundle(t *testing.T) {
@@ -76,67 +78,6 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
} }
} }
func TestGenerateBriefingWritesArtifact(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
path := filepath.Join(t.TempDir(), "daily.briefing.json")
result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg,
Resolved: resolved,
OutputPath: path,
})
if err != nil {
t.Fatalf("GenerateBriefing() error = %v", err)
}
if result.OutputPath != path {
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
}
if result.Package.Daily == nil {
t.Fatal("Daily = nil")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read briefing artifact: %v", err)
}
if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily"`) {
t.Fatalf("briefing artifact missing expected fields:\n%s", string(data))
}
}
func TestGenerateBriefingDefaultPath(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg,
Resolved: resolved,
})
if err != nil {
t.Fatalf("GenerateBriefing() error = %v", err)
}
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
}
}
func TestGenerateReportWritesReportAndPreflight(t *testing.T) { func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server) cfg := dailyTestConfig(t, server)
@@ -164,12 +105,18 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
runBody: "# Daily Report\n\nRain this morning.\n", runBody: "# Daily Report\n\nRain this morning.\n",
} }
outputPath := filepath.Join(t.TempDir(), "daily.md") outputPath := filepath.Join(t.TempDir(), "daily.md")
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
store := &recordingStore{Store: filesystemStore}
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,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -193,36 +140,47 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if renderer.runRequest.OutputPath != result.ReportPath { if renderer.runRequest.OutputPath != result.ReportPath {
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath) t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
} }
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath) if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
if err != nil {
t.Fatalf("read module snapshot: %v", err)
}
if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) {
t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData))
}
data, err := os.ReadFile(result.DataPackagePath) data, err := os.ReadFile(result.DataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
} }
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) { if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
}
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(data), "recent_changes:") ||
!strings.Contains(string(data), "current_conditions:") ||
!strings.Contains(string(data), "area_forecast_discussion:") {
t.Fatalf("data package missing expected content:\n%s", string(data)) t.Fatalf("data package missing expected content:\n%s", string(data))
} }
var savedDataPackage struct { savedDataPackage, err := promptinput.LoadYAML(data)
Report struct { if err != nil {
CurrentLocalDate string `json:"currentLocalDate"`
} `json:"report"`
Briefing briefing.Package `json:"briefing"`
}
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
t.Fatalf("decode data package: %v", err) t.Fatalf("decode data package: %v", err)
} }
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" { if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate) t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
} }
location := savedDataPackage.Briefing.Metadata.Location if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" { t.Fatal("data package metadata stanza missing")
t.Fatalf("data package location = %#v, want configured prompt location", location)
} }
current := savedDataPackage.Briefing.CurrentConditions current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 { if !ok || current["condition_text"] != "Clear" {
t.Fatalf("data package current conditions = %#v, want current conditions", current) t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
} }
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" { story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily) if !ok || story["title"] != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
} }
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") { if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data)) t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
@@ -237,7 +195,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if result.Metadata.RunID != resolved.Metadata().RunID { if result.Metadata.RunID != resolved.Metadata().RunID {
t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID) t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID)
} }
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath { if result.Metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || result.Metadata.DataPackagePath != result.DataPackagePath {
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata) t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
} }
if result.Metadata.RenderedReportPath != result.ReportPath { if result.Metadata.RenderedReportPath != result.ReportPath {
@@ -635,25 +593,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorDailyBriefing(priorResolved) savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -718,14 +658,18 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" { if result.Metadata.ReportID != report.DailyTomorrow || result.Metadata.Variant != "tomorrow" {
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant) t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
} }
if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" { dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate) if err != nil {
t.Fatalf("decode daily summary: %v", err)
} }
if result.Briefing.Daily.Planning == nil { if !ok || dailySummary["date"] != "2026-05-30" {
t.Fatal("Planning = nil, want tomorrow planning inputs") t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
}
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
t.Fatal("tomorrow_planning stanza missing")
} }
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") { if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") {
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath) t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
@@ -750,25 +694,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorDailyBriefing(priorResolved) savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -817,25 +743,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorThreeDayBriefing(priorResolved) savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportThreeDay, Report: ReportThreeDay,
@@ -859,11 +767,12 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Briefing.ThreeDay == nil { dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
t.Fatal("ThreeDay = nil") if err != nil {
t.Fatalf("decode daypart summaries: %v", err)
} }
if len(result.Briefing.ThreeDay.Days) != 3 { if !ok || len(dayparts) == 0 {
t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days)) t.Fatalf("daypart summaries = %#v, want 3-day module content", dayparts)
} }
if renderer.renderRequest.PromptID != "weather.three_day_outlook" { if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID) t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
@@ -893,25 +802,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err) t.Fatalf("ResolveGenerate(prior) error = %v", err)
} }
priorBriefing := priorWeekendBriefing(priorResolved) savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
priorPaths, err := store.Paths(priorResolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{ currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportWeekend, Report: ReportWeekend,
@@ -935,11 +826,12 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Briefing.Weekend == nil { dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
t.Fatal("Weekend = nil") if err != nil {
t.Fatalf("decode daypart summaries: %v", err)
} }
if len(result.Briefing.Weekend.Days) != 3 { if !ok || len(dayparts) == 0 {
t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days)) t.Fatalf("daypart summaries = %#v, want weekend module content", dayparts)
} }
if renderer.renderRequest.PromptID != "weather.weekend_outlook" { if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID) t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
@@ -984,14 +876,11 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Briefing.Storm == nil {
t.Fatal("Storm = nil")
}
if renderer.renderRequest.PromptID != "weather.storm_report" { if renderer.renderRequest.PromptID != "weather.storm_report" {
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID) t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
} }
if len(result.Briefing.Storm.HourlyPeriods) == 0 || len(result.Briefing.Storm.NarrativePeriods) == 0 { if _, ok := result.ModuleSnapshot.LookupStanza("precip_timing"); !ok {
t.Fatalf("selected source periods hourly/narrative = %d/%d, want relevant periods", len(result.Briefing.Storm.HourlyPeriods), len(result.Briefing.Storm.NarrativePeriods)) t.Fatal("precip_timing stanza missing")
} }
if _, err := os.Stat(outputPath); err != nil { if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("expected requested report output %q: %v", outputPath, err) t.Fatalf("expected requested report output %q: %v", outputPath, err)
@@ -1000,7 +889,7 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
} }
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) { if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") || !strings.Contains(string(data), "precip_timing:") {
t.Fatalf("data package missing storm content:\n%s", string(data)) t.Fatalf("data package missing storm content:\n%s", string(data))
} }
} }
@@ -1044,15 +933,15 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("InspectMetadata() error = %v", err) t.Fatalf("InspectMetadata() error = %v", err)
} }
if metadata.BriefingPath != result.BriefingPath || metadata.DataPackagePath != result.DataPackagePath { if metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || metadata.DataPackagePath != result.DataPackagePath {
t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata) t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata)
} }
briefingPackage, err := InspectBriefing(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) moduleSnapshot, err := InspectModules(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
if err != nil { if err != nil {
t.Fatalf("InspectBriefing() error = %v", err) t.Fatalf("InspectModules() error = %v", err)
} }
if briefingPackage.Metadata.RunID != result.Metadata.RunID { if moduleSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(moduleSnapshot.Outputs) == 0 {
t.Fatalf("briefing RunID = %q, want %q", briefingPackage.Metadata.RunID, result.Metadata.RunID) t.Fatalf("module snapshot = %#v, want persisted outputs", moduleSnapshot)
} }
dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
if err != nil { if err != nil {
@@ -1154,6 +1043,45 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
} }
} }
func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(`
reports:
tomorrow:
deterministic_modules:
- metadata
- alert_digest
- tomorrow_planning
`), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
now := mustParse("2026-05-29T18:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportTomorrow,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
want := []module.ID{module.Metadata, module.AlertDigest, module.TomorrowPlanning}
if got := resolved.Definition.ModuleIDs(); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(want), ",") {
t.Fatalf("ModuleIDs() = %#v, want %#v", got, want)
}
}
func moduleIDsForTest(ids []module.ID) []string {
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, string(id))
}
return out
}
func dailyBundleServer(t *testing.T) *httptest.Server { func dailyBundleServer(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) {
@@ -1382,13 +1310,79 @@ func assertPathsExist(t *testing.T, paths ...string) {
} }
} }
func priorDailyBriefing(resolved report.Resolved) briefing.Package { func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) {
low := 50.0 t.Helper()
high := 58.0 moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
precip := 10.0 if err != nil {
return briefing.Package{ t.Fatalf("SaveModuleSnapshot() error = %v", err)
Metadata: briefing.Metadata{ }
SchemaVersion: briefing.SchemaVersion, paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadataFromBriefingMetadata(resolved, appBriefingMetadata(resolved), state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
}
func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Snapshot {
t.Helper()
low := 50
high := 58
precip := 10
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"low_temp_f": low,
"high_temp_f": high,
"max_pop_percent": precip,
}},
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
"morning": map[string]any{
"period": timeutil.Period{Start: resolved.ValidPeriod.Start.Add(6 * time.Hour), End: resolved.ValidPeriod.Start.Add(10 * time.Hour)},
"temp_range_f": "50-58",
},
}},
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
"max_pop_percent": precip,
"max_pop_time": "6 AM",
}},
{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]any{}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
t.Helper()
precip := 10
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
date + "_morning": map[string]any{
"period": timeutil.Period{Start: mustParse(date + "T06:00:00Z"), End: mustParse(date + "T10:00:00Z")},
"temp_range_f": "50-58",
"max_pop_percent": precip,
"max_pop_time": "6 AM",
},
}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func appBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Metadata{
RunID: resolved.Metadata().RunID, RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
Variant: "today", Variant: "today",
@@ -1397,77 +1391,6 @@ func priorDailyBriefing(resolved report.Resolved) briefing.Package {
Units: "us", Units: "us",
Timezone: resolved.Timezone, Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod, ValidPeriod: resolved.ValidPeriod,
},
Daily: &briefing.Daily{
BottomLine: briefing.BottomLine{
Temperature: forecast.Range{Min: &low, Max: &high},
MaxPrecipProbability: &forecast.TimedValue{
Value: precip,
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
},
},
Dayparts: []forecast.DaypartSummary{
{Name: "morning"},
},
},
}
}
func priorThreeDayBriefing(resolved report.Resolved) briefing.Package {
high := 58.0
precip := 10.0
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
ThreeDay: &briefing.ThreeDay{
Days: []briefing.OutlookDay{
{
Date: "2026-05-29",
Temperature: forecast.Range{Max: &high},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
},
},
},
},
}
}
func priorWeekendBriefing(resolved report.Resolved) briefing.Package {
high := 58.0
precip := 10.0
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Weekend: &briefing.Weekend{
Days: []briefing.OutlookDay{
{
Date: "2026-05-30",
Temperature: forecast.Range{Max: &high},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: resolved.ValidPeriod.Start.Add(12 * time.Hour),
},
},
},
},
} }
} }
@@ -1483,6 +1406,36 @@ type recordingRenderer struct {
runBody string runBody string
} }
type recordingStore struct {
state.Store
calls []string
}
func (s *recordingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
s.calls = append(s.calls, "module_snapshot")
return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
}
func (s *recordingStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
s.calls = append(s.calls, "data_package")
return s.Store.SaveDataPackage(ctx, resolved, pkg)
}
func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Resolved, artifact state.PreflightArtifact) (string, error) {
s.calls = append(s.calls, "preflight")
return s.Store.SavePreflight(ctx, resolved, artifact)
}
func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
s.calls = append(s.calls, "prepare_report")
return s.Store.PrepareRenderedReport(ctx, resolved)
}
func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
s.calls = append(s.calls, "metadata")
return s.Store.SaveMetadata(ctx, metadata)
}
func successfulRenderer(body string) *recordingRenderer { func successfulRenderer(body string) *recordingRenderer {
return &recordingRenderer{ return &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0}, renderResult: &scriptorium.RenderResult{ExitCode: 0},

View File

@@ -6,11 +6,12 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "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/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"
) )
type InspectReportsRequest struct { type InspectReportsRequest struct {
@@ -28,7 +29,7 @@ type SourceInspection struct {
ReportID report.ID `json:"reportId"` ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"` Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
} }
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) { func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
@@ -44,12 +45,12 @@ func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata
return inspection.metadata, err return inspection.metadata, err
} }
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) { func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
inspection, err := inspectRun(ctx, req) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return briefing.Package{}, err return module.Snapshot{}, err
} }
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath) return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
} }
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) { func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {

View File

@@ -0,0 +1,269 @@
package briefing
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type MetadataModule struct {
RunID string `json:"run_id"`
ReportID report.ID `json:"report_id"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"prompt_id"`
GeneratedAt time.Time `json:"generated_at"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"valid_period"`
Location *LocationContext `json:"location,omitempty"`
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
Alerts *AlertDigestModule `json:"alerts,omitempty"`
}
type SourceWarningSummary struct {
Source string `json:"source"`
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message"`
CompletenessImpact string `json:"completeness_impact,omitempty"`
}
type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"`
}
type AlertDigestModule struct {
Checked bool `json:"checked"`
ActiveCount int `json:"active_count"`
RelevantCount int `json:"relevant_count"`
Missing bool `json:"missing,omitempty"`
Relevant []AlertSummary `json:"relevant,omitempty"`
}
type AlertSummary struct {
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
Severity string `json:"severity,omitempty"`
}
type AreaForecastDiscussionModule struct {
Product string `json:"product,omitempty"`
KeyMessages []string `json:"key_messages,omitempty"`
ShortTerm string `json:"short_term,omitempty"`
LongTerm string `json:"long_term,omitempty"`
}
type WeatherStoryModule struct {
Available bool `json:"available"`
OfficeID string `json:"office_id,omitempty"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AltText string `json:"alt_text,omitempty"`
Priority bool `json:"priority"`
Order int `json:"order"`
DownloadURL string `json:"download_url,omitempty"`
}
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
metadata := ctx.Resolved.Metadata()
value := MetadataModule{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Units: ctx.Units,
Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
}
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
}
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
current := ctx.Collected.Current
if current == nil {
return nil, nil
}
value := CurrentConditionsModule{
ConditionText: current.ConditionText,
IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF),
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
DewpointC: copyFloat(current.DewpointC),
DewpointF: copyFloat(current.DewpointF),
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph),
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
}
if value.isEmpty() {
return nil, nil
}
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
}
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
if value == nil {
value = &AlertDigestModule{}
}
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
}
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
discussion := ctx.Collected.Discussion
if discussion == nil {
return nil, nil
}
opts, ok := options.(module.AreaForecastDiscussionOptions)
if !ok {
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
}
sections, err := areaForecastDiscussionSections(opts)
if err != nil {
return nil, err
}
value := AreaForecastDiscussionModule{}
if sections["product"] {
value.Product = discussion.Product
}
if sections["key_messages"] {
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
}
if sections["short_term"] && discussion.ShortTerm != nil {
value.ShortTerm = discussion.ShortTerm.Text
}
if sections["long_term"] && discussion.LongTerm != nil {
value.LongTerm = discussion.LongTerm.Text
}
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
return nil, nil
}
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
}
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
if len(options.Sections) == 0 {
return map[string]bool{
"product": true,
"key_messages": true,
"short_term": true,
"long_term": true,
}, nil
}
sections := map[string]bool{}
for _, section := range options.Sections {
switch section {
case "product", "key_messages", "short_term", "long_term":
sections[section] = true
default:
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
}
}
return sections, nil
}
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
story := ctx.Collected.WeatherStory
if story == nil {
return nil, nil
}
value := WeatherStoryModule{
Available: true,
OfficeID: story.OfficeID,
StartTime: story.StartTime,
EndTime: story.EndTime,
UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title,
Description: story.Description,
AltText: story.AltText,
Priority: story.Priority,
Order: story.Order,
DownloadURL: story.DownloadURL,
}
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
}
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
out := make([]SourceWarningSummary, 0, len(warnings))
for _, warning := range warnings {
out = append(out, SourceWarningSummary{
Source: warning.Source,
Code: warning.Code,
Severity: warning.Severity,
Message: warning.Message,
CompletenessImpact: warning.CompletenessImpact,
})
}
return out
}
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
missing := sourceMissing(collected.SourceProvenance, "alerts")
if collected.Alerts == nil && !missing {
return nil
}
value := &AlertDigestModule{Missing: missing}
if collected.Alerts != nil {
value.Checked = true
value.ActiveCount = len(collected.Alerts.Alerts)
}
value.RelevantCount = len(overlaps)
for _, overlap := range overlaps {
value.Relevant = append(value.Relevant, AlertSummary{
Event: overlap.Event,
Headline: overlap.Headline,
Severity: overlap.Severity,
})
}
return value
}
func sourceMissing(sources []weatherdata.Source, name string) bool {
for _, source := range sources {
if source.Name == name && source.Missing {
return true
}
}
return false
}
func (v CurrentConditionsModule) isEmpty() bool {
return v.ConditionText == "" &&
v.IsDay == nil &&
v.TemperatureC == nil &&
v.TemperatureF == nil &&
v.ApparentTemperatureC == nil &&
v.ApparentTemperatureF == nil &&
v.DewpointC == nil &&
v.DewpointF == nil &&
v.RelativeHumidityPercent == nil &&
v.WindSpeedKmh == nil &&
v.WindSpeedMph == nil &&
v.WindDirectionDegrees == nil
}

View File

@@ -0,0 +1,296 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
tests := []struct {
id module.ID
stanza string
}{
{id: module.Metadata, stanza: "metadata"},
{id: module.CurrentConditions, stanza: "current_conditions"},
{id: module.AlertDigest, stanza: "alert_digest"},
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
{id: module.WeatherStory, stanza: "weather_story"},
}
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil {
t.Fatal("BuildModule() output = nil, want stanza")
}
if output.ID != tt.id || output.StanzaName != tt.stanza {
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
}
})
}
}
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[MetadataModule](t, output)
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
t.Fatalf("metadata = %#v, want report identity", value)
}
if value.Location == nil || value.Location.Name != "Brentwood" {
t.Fatalf("Location = %#v, want configured location", value.Location)
}
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
}
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal metadata: %v", err)
}
jsonText := string(data)
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
}
}
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[CurrentConditionsModule](t, output)
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal current conditions: %v", err)
}
jsonText := string(data)
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field)
}
}
}
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Alerts = &weatherdata.AlertRun{}
ctx.Derived.AlertOverlaps = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(checked empty) error = %v", err)
}
checked := moduleValue[AlertDigestModule](t, output)
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
}
ctx.Collected.Alerts = nil
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(missing) error = %v", err)
}
missing := moduleValue[AlertDigestModule](t, output)
if missing.Checked || !missing.Missing {
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
}
}
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Current = nil
ctx.Collected.Discussion = nil
ctx.Collected.WeatherStory = nil
for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
if err != nil {
t.Fatalf("BuildModule(%s) error = %v", id, err)
}
if output != nil {
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
}
}
}
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
if err != nil {
t.Fatalf("BuildModule(afd) error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
t.Fatalf("AFD = %#v, want discussion sections", afd)
}
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
if err != nil {
t.Fatalf("BuildModule(weather story) error = %v", err)
}
story := moduleValue[WeatherStoryModule](t, storyOutput)
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
}
data, err := json.Marshal(storyOutput.Value)
if err != nil {
t.Fatalf("Marshal weather story: %v", err)
}
if !strings.Contains(string(data), "download_url") {
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
}
}
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
}
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want only short_term section", afd)
}
}
func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
resolved := report.Resolved{
Definition: definition,
GeneratedAt: generatedAt,
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
},
}
isDay := true
tempF := 74.0
apparentF := 76.0
humidity := 71.0
windMph := 8.0
windDirection := 190.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{
Resolved: resolved,
Collected: facts.CollectedFacts{
Current: &weatherdata.Current{
ConditionText: "Partly cloudy",
IsDay: &isDay,
TemperatureF: &tempF,
ApparentTemperatureF: &apparentF,
RelativeHumidityPercent: &humidity,
WindSpeedMph: &windMph,
WindDirectionDegrees: &windDirection,
},
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
}},
Discussion: &weatherdata.Discussion{
Product: "discussion",
KeyMessages: []string{"Scattered showers are possible."},
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
},
WeatherStory: &weatherdata.WeatherStory{
OfficeID: "LSX",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
UpdatedAt: &updatedAt,
Title: "Rain Chances",
Description: "Scattered showers are possible.",
AltText: "Weather story graphic with rain chances.",
Priority: true,
Order: 1,
DownloadURL: "https://example.invalid/story.png",
},
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
SourceWarnings: []weatherdata.SourceWarning{{
Source: "daily",
Code: "missing_source",
Severity: "warning",
Message: "daily source is missing",
Endpoint: "/forecast/daily",
CompletenessImpact: "source omitted",
}},
},
Derived: facts.DerivedFacts{
AlertOverlaps: []forecast.AlertOverlap{{
Event: "Flood Watch",
Headline: "Flooding possible",
Severity: "Moderate",
}},
},
Units: "us",
Timezone: "America/Chicago",
Location: &LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
}
}
func moduleValue[T any](t *testing.T, output *module.Output) T {
t.Helper()
var value T
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal module value: %v", err)
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode module value: %v", err)
}
return value
}
func mustParseModuleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -1,327 +0,0 @@
package briefing
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
bundle := loadBundleFixture(t)
currentIsDay := true
currentTemp := 75.9
currentFeelsLike := 76.1
currentHumidity := 56.0
currentWind := 10.7
bundle.Current = &forecast.Current{
ConditionText: "Partly cloudy",
IsDay: &currentIsDay,
TemperatureF: &currentTemp,
ApparentTemperatureF: &currentFeelsLike,
RelativeHumidityPercent: &currentHumidity,
WindSpeedMph: &currentWind,
}
bundle.Sources[0].DataSHA256 = "abc123"
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{
Resolved: resolved,
Bundle: bundle,
Units: "us",
Timezone: "America/Chicago",
Location: &LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Metadata.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
}
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
}
if pkg.Metadata.ReportID != report.DailyToday {
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
}
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
}
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
}
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
}
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
}
if len(pkg.Metadata.SourceWarnings) != 1 {
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
}
if pkg.Daily == nil {
t.Fatal("Daily = nil")
}
if len(pkg.Daily.Dayparts) != 5 {
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
}
if len(pkg.Daily.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
}
if len(pkg.Daily.NarrativePeriods) != 1 {
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
}
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
}
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
}
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
}
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
}
if pkg.Daily.BottomLine.Summary == "" {
t.Fatal("BottomLine summary is empty")
}
if _, err := json.Marshal(pkg); err != nil {
t.Fatalf("briefing package is not JSON inspectable: %v", err)
}
}
func TestDailyBriefingQuietWeather(t *testing.T) {
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
}},
Alerts: &forecast.AlertRun{},
Sources: []forecast.Source{
{Name: "hourly", FetchedAt: time.Now()},
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
},
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
}
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
}
if pkg.CurrentConditions != nil {
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
}
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
}
if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
}
if pkg.Metadata.Alerts == nil {
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
}
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
}
data, err := json.Marshal(pkg.Metadata.Alerts)
if err != nil {
t.Fatalf("marshal alert metadata: %v", err)
}
if strings.Contains(string(data), `"missing"`) {
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
}
}
func TestDailyBriefingAlertExclusion(t *testing.T) {
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
bundle := loadBundleFixture(t)
bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
}}
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
}
}
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
Now: mustParse("2026-05-29T18:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve tomorrow: %v", err)
}
precip := 70.0
wind := 34.0
summary := &forecast.DailySummary{
Date: "2026-05-30",
Period: resolved.ValidPeriod,
Dayparts: []forecast.DaypartSummary{
{
Name: "overnight",
Period: timeutil.Period{
Start: mustParse("2026-05-30T00:00:00-05:00"),
End: mustParse("2026-05-30T06:00:00-05:00"),
},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: 40,
Time: mustParse("2026-05-30T03:00:00-05:00"),
},
},
{
Name: "morning",
Period: timeutil.Period{
Start: mustParse("2026-05-30T06:00:00-05:00"),
End: mustParse("2026-05-30T12:00:00-05:00"),
},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: mustParse("2026-05-30T08:00:00-05:00"),
},
PeakWindGust: &forecast.TimedValue{
Value: wind,
Time: mustParse("2026-05-30T09:00:00-05:00"),
},
Indicators: forecast.Indicators{Snow: true},
},
},
}
pkg, err := BuildDaily(BuildContext{
Resolved: resolved,
Units: "us",
Timezone: "America/Chicago",
}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
}
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
}
if pkg.Daily.Planning == nil {
t.Fatal("Planning = nil, want tomorrow planning inputs")
}
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
}
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
}
}
func TestSaveBriefingPackage(t *testing.T) {
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
if err := Save(path, pkg); err != nil {
t.Fatalf("Save() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read briefing: %v", err)
}
if !strings.Contains(string(data), SchemaVersion) {
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
}
}
func loadBundleFixture(t *testing.T) *forecast.Bundle {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
if err != nil {
t.Fatalf("read bundle fixture: %v", err)
}
var bundle forecast.Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode bundle fixture: %v", err)
}
return &bundle
}
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve daily: %v", err)
}
return resolved
}
func defaultDayparts() []forecast.DaypartDefinition {
return []forecast.DaypartDefinition{
{Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "10:00"},
{Name: "midday", Start: "10:00", End: "15:00"},
{Name: "afternoon", Start: "15:00", End: "17:00"},
{Name: "evening", Start: "17:00", End: "24:00"},
}
}
func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod {
return forecast.ForecastPeriod{
StartTime: mustParse(start),
EndTime: mustParse(end),
TextDescription: "Clear",
TemperatureF: &temperature,
}
}
func mustLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("load location: %v", err)
}
return location
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,330 @@
package briefing
import (
"fmt"
"strings"
"time"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type DerivedDailySummaryModule struct {
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopWindow string `json:"max_pop_window,omitempty"`
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
LastPrecipHour string `json:"last_precip_hour,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
DominantConditions []string `json:"dominant_conditions,omitempty"`
Hazards []string `json:"hazards,omitempty"`
}
type DerivedDaypartSummaryModule struct {
Period timeutil.Period `json:"period"`
TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
}
type PrecipTimingModule struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
FirstPrecipHour string `json:"first_precip_hour,omitempty"`
LastPrecipHour string `json:"last_precip_hour,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
}
type OutdoorWindowsModule struct {
Best *OutdoorWindowModule `json:"best,omitempty"`
Worst *OutdoorWindowModule `json:"worst,omitempty"`
}
type OutdoorWindowModule struct {
Daypart string `json:"daypart"`
Start string `json:"start"`
End string `json:"end"`
Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"`
}
type TomorrowPlanningModule struct {
MorningReadiness []string `json:"morning_readiness,omitempty"`
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
}
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
summary := ctx.Derived.FirstDailySummary()
if summary == nil {
return nil, fmt.Errorf("daily summary facts are required")
}
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
if err != nil {
return nil, err
}
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
}
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
if len(ctx.Derived.DaypartSummaries) == 0 {
return nil, fmt.Errorf("daypart summary facts are required")
}
value := map[string]DerivedDaypartSummaryModule{}
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
for _, daypart := range ctx.Derived.DaypartSummaries {
key := daypartKey(daypart, prefixDates)
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
}
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
}
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
}
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
value := OutdoorWindowsModule{
Best: outdoorWindowValue(windows.Best),
Worst: outdoorWindowValue(windows.Worst),
}
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
}
func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
summary := ctx.Derived.FirstDailySummary()
if summary == nil {
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil
}
planning := buildTomorrowPlanning(summary)
value := TomorrowPlanningModule{}
if planning != nil {
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
}
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil
}
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
value := DerivedDailySummaryModule{
Date: summary.Date,
ThunderMentioned: timing.ThunderMentioned,
}
conditions := map[string]struct{}{}
hazards := map[string]struct{}{}
var temperature forecast.Range
var apparent forecast.Range
var maxPop *forecast.TimedValue
var maxGust *forecast.TimedValue
var maxPopWindow timeutil.Period
for _, daypart := range summary.Dayparts {
addRange(&temperature, daypart.Temperature)
addRange(&apparent, daypart.ApparentTemperature)
if daypart.MaxPrecipitationProbability != nil {
if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value {
copied := *daypart.MaxPrecipitationProbability
maxPop = &copied
maxPopWindow = daypart.Period
}
}
maxTimedValue(&maxGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
hazards[hazard] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
value.HighTempF = roundedInt(temperature.Max)
value.LowTempF = roundedInt(temperature.Min)
value.HeatIndexMaxF = roundedInt(apparent.Max)
if maxPop != nil {
value.MaxPopPercent = roundedInt(&maxPop.Value)
value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone)
}
if maxGust != nil {
value.MaxWindGustMph = roundedInt(&maxGust.Value)
}
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
value.DominantConditions = sortedSet(conditions)
value.Hazards = sortedSet(hazards)
return value, nil
}
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
value := DerivedDaypartSummaryModule{
Period: daypart.Period,
TempRangeF: rangeLabel(daypart.Temperature),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition,
NotableConditions: append([]string(nil), daypart.NotableConditions...),
Snow: daypart.Indicators.Snow,
Ice: daypart.Indicators.Ice,
Fog: daypart.Indicators.Fog,
Heat: daypart.Indicators.Heat,
Cold: daypart.Indicators.Cold,
Wind: daypart.Indicators.Wind,
RelevantAlertCount: len(daypart.AlertOverlaps),
}
if daypart.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
}
if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
}
return value
}
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned}
if timing.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
}
value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone)
value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone)
return value
}
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
if window == nil {
return nil
}
return &OutdoorWindowModule{
Daypart: window.Daypart,
Start: window.Start,
End: window.End,
Reasons: append([]string(nil), window.Reasons...),
Score: window.Score,
}
}
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
seen := map[string]struct{}{}
for _, summary := range summaries {
seen[summary.Date] = struct{}{}
}
return len(seen) > 1
}
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
key := normalizedKey(daypart.Name)
if key == "" {
key = "unnamed"
}
if !prefixDate {
return key
}
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
}
func normalizedKey(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
var out strings.Builder
lastUnderscore := false
for _, r := range lower {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
out.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(out.String(), "_")
}
func rangeLabel(value forecast.Range) string {
if value.Min == nil && value.Max == nil {
return ""
}
if value.Min != nil && value.Max != nil {
low := roundedInt(value.Min)
high := roundedInt(value.Max)
if low != nil && high != nil && *low == *high {
return fmt.Sprintf("%d", *low)
}
return fmt.Sprintf("%d-%d", *low, *high)
}
if value.Min != nil {
low := roundedInt(value.Min)
return fmt.Sprintf("%d", *low)
}
high := roundedInt(value.Max)
return fmt.Sprintf("%d", *high)
}
func daypartApparentRangeLabel(value forecast.Range) string {
if value.Min == nil && value.Max == nil {
return ""
}
return rangeLabel(value)
}
func roundedInt(value *float64) *int {
if value == nil {
return nil
}
rounded := int(*value + 0.5)
if *value < 0 {
rounded = int(*value - 0.5)
}
return &rounded
}
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil {
return ""
}
return clockLabel(value.Time, timezone)
}
func periodClockLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
return ""
}
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
}
func clockLabel(value time.Time, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
label := value.In(location).Format("3 PM")
if label == "12 AM" && value.In(location).Minute() == 0 {
return "12 AM"
}
return label
}

View File

@@ -0,0 +1,240 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[DerivedDailySummaryModule](t, output)
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
t.Fatalf("daily temperatures = %#v/%#v, want 96/31", value.HighTempF, value.LowTempF)
}
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || value.MaxPopWindow != "12 PM-6 PM" {
t.Fatalf("max precip = %#v %q, want 80 and afternoon window", value.MaxPopPercent, value.MaxPopWindow)
}
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
}
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
}
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily summary: %v", err)
}
jsonText := string(data)
for _, field := range []string{"high_temp_f", "low_temp_f", "max_pop_percent", "first_precip_hour", "heat_index_max_f"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "qpf") {
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
}
}
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule(rainy) error = %v", err)
}
rainy := moduleValue[PrecipTimingModule](t, output)
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.FirstPrecipHour != "8 AM" || rainy.LastPrecipHour != "1 PM" || !rainy.ThunderMentioned {
t.Fatalf("rainy precip timing = %#v, want peak, first/last, thunder", rainy)
}
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule(dry) error = %v", err)
}
dry := moduleValue[PrecipTimingModule](t, output)
if dry.FirstPrecipHour != "" || dry.LastPrecipHour != "" || dry.ThunderMentioned {
t.Fatalf("dry precip timing = %#v, want no precip hours and no thunder", dry)
}
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
}
}
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
morning, ok := value["morning"]
if !ok {
t.Fatalf("daypart keys = %#v, want configured morning key", value)
}
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
}
afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
}
overnight := value["overnight"]
if !overnight.Cold {
t.Fatalf("overnight = %#v, want cold hazard", overnight)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daypart summaries: %v", err)
}
jsonText := string(data)
for _, field := range []string{"temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
}
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyTomorrow)
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
if err != nil {
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
}
outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput)
if outdoor.Best == nil || outdoor.Worst == nil {
t.Fatalf("outdoor windows = %#v, want best and worst", outdoor)
}
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
}
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
if err != nil {
t.Fatalf("BuildModule(tomorrow planning) error = %v", err)
}
planning := moduleValue[TomorrowPlanningModule](t, planningOutput)
if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 {
t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning)
}
data, err := json.Marshal(planningOutput.Value)
if err != nil {
t.Fatalf("marshal tomorrow planning: %v", err)
}
if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") {
t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data))
}
}
func TestDerivedModulesHandleMissingData(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil {
t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error")
}
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil {
t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error")
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
if err != nil {
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
}
windows := moduleValue[OutdoorWindowsModule](t, output)
if windows.Best != nil || windows.Worst != nil {
t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows)
}
}
func derivedModuleContext(id report.ID) ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(id)
summary := forecast.DailySummary{
Date: "2026-05-29",
Period: timeutil.Period{
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{
derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5),
derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15),
derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42),
},
}
hours := []weatherdata.ForecastPeriod{
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
}
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
return ModuleContext{
Resolved: report.Resolved{
Definition: definition,
GeneratedAt: generatedAt,
Timezone: "America/Chicago",
ValidPeriod: summary.Period,
},
Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: hours,
DailySummaries: []forecast.DailySummary{summary},
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
PrecipTiming: forecast.BuildPrecipTiming(hours),
},
Units: "us",
Timezone: "America/Chicago",
}
}
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
hour := derivedHour(start, text, precip, temperature, apparent, gust)
return forecast.SummarizeDaypart(name, timeutil.Period{
Start: mustParseModuleTime(start),
End: mustParseModuleTime(end),
}, []weatherdata.ForecastPeriod{hour})
}
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
startTime := mustParseModuleTime(start)
endTime := startTime.Add(time.Hour)
return weatherdata.ForecastPeriod{
StartTime: startTime,
EndTime: endTime,
TextDescription: text,
TemperatureF: floatPtr(temperature),
ApparentTemperatureF: apparent,
ProbabilityOfPrecipitationPercent: floatPtr(precip),
WindGustMph: floatPtr(gust),
}
}
func floatPtr(value float64) *float64 {
return &value
}

View File

@@ -0,0 +1,298 @@
package briefing
import (
"fmt"
"reflect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type ModuleContext struct {
Resolved report.Resolved
Collected facts.CollectedFacts
Derived facts.DerivedFacts
Units string
Timezone string
Location *LocationContext
}
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
type ModuleDefinition struct {
ID module.ID
StanzaName string
DefaultOptions any
RequiredCollected []module.FactRequirement
RequiredDerived []module.FactRequirement
SupportedReports []report.ID
MissingData module.MissingDataBehavior
AllowDuplicate bool
Builder ModuleBuilder
}
type ModuleRegistry struct {
definitions map[module.ID]ModuleDefinition
}
func DefaultModuleRegistry() (ModuleRegistry, error) {
return NewModuleRegistry(defaultModuleDefinitions())
}
func MustDefaultModuleRegistry() ModuleRegistry {
registry, err := DefaultModuleRegistry()
if err != nil {
panic(err)
}
return registry
}
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
seenStanzas := map[string]module.ID{}
for i, definition := range definitions {
if definition.ID == "" {
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
}
if definition.StanzaName == "" {
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
}
if _, ok := registry.definitions[definition.ID]; ok {
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
}
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
}
seenStanzas[definition.StanzaName] = definition.ID
registry.definitions[definition.ID] = definition
}
return registry, nil
}
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
definition, ok := r.definitions[id]
if !ok {
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
}
return definition, nil
}
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
definition, err := r.Lookup(item.ID)
if err != nil {
return nil, err
}
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
}
if err := definition.ValidateOptions(item.Options); err != nil {
return nil, err
}
if definition.Builder == nil {
return nil, fmt.Errorf("module %q has no builder", item.ID)
}
options := item.Options
if options == nil {
options = definition.DefaultOptions
}
output, err := definition.Builder(ctx, options)
if err != nil {
return nil, err
}
if output == nil {
return nil, nil
}
if output.ID != definition.ID {
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
}
if output.StanzaName != definition.StanzaName {
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
}
return output, nil
}
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
seenModules := map[module.ID]struct{}{}
seenStanzas := map[string]module.ID{}
for i, item := range items {
definition, err := r.Lookup(item.ID)
if err != nil {
return fmt.Errorf("modules[%d]: %w", i, err)
}
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
}
seenModules[item.ID] = struct{}{}
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
}
seenStanzas[definition.StanzaName] = item.ID
if !definition.SupportsReport(reportID) {
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
}
if err := definition.ValidateOptions(item.Options); err != nil {
return fmt.Errorf("modules[%d]: %w", i, err)
}
}
return nil
}
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
if len(d.SupportedReports) == 0 {
return true
}
for _, supported := range d.SupportedReports {
if supported == id {
return true
}
}
return false
}
func (d ModuleDefinition) ValidateOptions(options any) error {
if options == nil {
return nil
}
if d.DefaultOptions == nil {
return fmt.Errorf("module %q does not accept options", d.ID)
}
want := reflect.TypeOf(d.DefaultOptions)
got := reflect.TypeOf(options)
if got == want {
return nil
}
if got.Kind() == reflect.Pointer && got.Elem() == want {
return nil
}
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
}
func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildMetadataModule,
},
{
ID: module.CurrentConditions,
StanzaName: "current_conditions",
DefaultOptions: module.CurrentConditionsOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule,
},
{
ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule,
},
{
ID: module.DerivedDaypartSummaries,
StanzaName: "derived_daypart_summaries",
DefaultOptions: module.DerivedDaypartSummariesOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
SupportedReports: daypartReports,
MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule,
},
{
ID: module.HourlyTable,
StanzaName: "hourly_table",
DefaultOptions: module.HourlyTableOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.Storm},
MissingData: module.MissingDataError,
},
{
ID: module.PrecipTiming,
StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule,
},
{
ID: module.AlertDigest,
StanzaName: "alert_digest",
DefaultOptions: module.AlertDigestOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule,
},
{
ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion",
DefaultOptions: module.AreaForecastDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule,
},
{
ID: module.WeatherStory,
StanzaName: "weather_story",
DefaultOptions: module.WeatherStoryOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildWeatherStoryModule,
},
{
ID: module.ForecastDelta,
StanzaName: "forecast_delta",
DefaultOptions: module.ForecastDeltaOptions{},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay},
MissingData: module.MissingDataEmpty,
},
{
ID: module.OutdoorWindows,
StanzaName: "outdoor_windows",
DefaultOptions: module.OutdoorWindowsOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
SupportedReports: daypartReports,
MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule,
},
{
ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.DailyTomorrow},
MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule,
},
{
ID: module.WeekendPlanning,
StanzaName: "weekend_planning",
DefaultOptions: module.WeekendPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Weekend},
MissingData: module.MissingDataEmpty,
},
{
ID: module.StormWindowSummary,
StanzaName: "storm_window_summary",
DefaultOptions: module.StormWindowSummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedStormWindowSummary},
SupportedReports: []report.ID{report.Storm},
MissingData: module.MissingDataError,
},
}
}

View File

@@ -0,0 +1,76 @@
package briefing
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, definition := range report.DefaultRegistry().All() {
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
}
}
}
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
t.Fatalf("error = %v, want unknown module", err)
}
}
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
{ID: module.Metadata},
{ID: module.Metadata},
})
if err == nil || !strings.Contains(err.Error(), `duplicate module "metadata"`) {
t.Fatalf("error = %v, want duplicate module", err)
}
}
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}},
})
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
t.Fatalf("error = %v, want duplicate stanza name", err)
}
}
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.StormWindowSummary}})
if err == nil || !strings.Contains(err.Error(), `module "storm_window_summary" is not compatible with report "daily_today"`) {
t.Fatalf("error = %v, want incompatible report", err)
}
}
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
})
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
t.Fatalf("error = %v, want invalid option shape", err)
}
}
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
{ID: module.Metadata, Options: module.MetadataOptions{}},
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
})
if err != nil {
t.Fatalf("ValidateComposition() error = %v", err)
}
}

View File

@@ -1,29 +1,15 @@
// Package briefing builds report-specific structured briefing packages. // Package briefing builds prompt-facing module values.
package briefing package briefing
import ( import (
"fmt"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct {
Metadata Metadata `json:"metadata"`
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
Daily *Daily `json:"daily,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
}
type Metadata struct { type Metadata struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"` RunID string `json:"runId"`
ReportID report.ID `json:"reportId"` ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty"`
@@ -36,7 +22,7 @@ type Metadata struct {
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"` Alerts *AlertStatus `json:"alerts,omitempty"`
} }
@@ -47,21 +33,6 @@ type LocationContext struct {
Timezone string `json:"timezone,omitempty"` Timezone string `json:"timezone,omitempty"`
} }
type CurrentConditionsContext struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
}
type SourceMetadata struct { type SourceMetadata struct {
Name string `json:"name"` Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"` Endpoint string `json:"endpoint,omitempty"`
@@ -70,7 +41,7 @@ type SourceMetadata struct {
UpdatedAt *time.Time `json:"updatedAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"` DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"` Missing bool `json:"missing,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"` Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
} }
type AlertStatus struct { type AlertStatus struct {
@@ -82,7 +53,7 @@ type AlertStatus struct {
type BuildContext struct { type BuildContext struct {
Resolved report.Resolved Resolved report.Resolved
Bundle *forecast.Bundle Bundle *weatherdata.Bundle
Units string Units string
Timezone string Timezone string
Location *LocationContext Location *LocationContext
@@ -92,7 +63,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
metadata := ctx.Resolved.Metadata() metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
return Metadata{ return Metadata{
SchemaVersion: SchemaVersion,
RunID: metadata.RunID, RunID: metadata.RunID,
ReportID: metadata.ReportID, ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID), Variant: variantForReport(metadata.ReportID),
@@ -110,13 +80,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
} }
} }
func buildPackage(ctx BuildContext) Package {
return Package{
Metadata: BuildMetadata(ctx),
CurrentConditions: currentConditions(ctx.Bundle),
}
}
func copyLocation(location *LocationContext) *LocationContext { func copyLocation(location *LocationContext) *LocationContext {
if location == nil { if location == nil {
return nil return nil
@@ -125,42 +88,6 @@ func copyLocation(location *LocationContext) *LocationContext {
return &copied return &copied
} }
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
if bundle == nil || bundle.Current == nil {
return nil
}
current := bundle.Current
context := CurrentConditionsContext{
ConditionText: current.ConditionText,
IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF),
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
DewpointC: copyFloat(current.DewpointC),
DewpointF: copyFloat(current.DewpointF),
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph),
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
}
if context.ConditionText == "" &&
context.IsDay == nil &&
context.TemperatureC == nil &&
context.TemperatureF == nil &&
context.ApparentTemperatureC == nil &&
context.ApparentTemperatureF == nil &&
context.DewpointC == nil &&
context.DewpointF == nil &&
context.RelativeHumidityPercent == nil &&
context.WindSpeedKmh == nil &&
context.WindSpeedMph == nil &&
context.WindDirectionDegrees == nil {
return nil
}
return &context
}
func copyBool(value *bool) *bool { func copyBool(value *bool) *bool {
if value == nil { if value == nil {
return nil return nil
@@ -177,18 +104,19 @@ func copyFloat(value *float64) *float64 {
return &copied return &copied
} }
func Save(path string, pkg Package) error { func copyTime(value *time.Time) *time.Time {
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil { if value == nil {
return fmt.Errorf("save briefing package: %w", err)
}
return nil return nil
} }
copied := *value
return &copied
}
func sourceLocation(bundle *forecast.Bundle) (string, string) { func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
if bundle == nil { if bundle == nil {
return "", "" return "", ""
} }
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} { for _, run := range []*weatherdata.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
if run == nil { if run == nil {
continue continue
} }
@@ -199,7 +127,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
return "", "" return "", ""
} }
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata { func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
if bundle == nil { if bundle == nil {
return nil return nil
} }
@@ -219,14 +147,14 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
return out return out
} }
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning { func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
if bundle == nil { if bundle == nil {
return nil return nil
} }
return bundle.Warnings return bundle.Warnings
} }
func alertStatus(bundle *forecast.Bundle) *AlertStatus { func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
if bundle == nil { if bundle == nil {
return nil return nil
} }
@@ -247,16 +175,6 @@ func alertStatus(bundle *forecast.Bundle) *AlertStatus {
return status return status
} }
func setRelevantAlertCount(metadata *Metadata, count int) {
if metadata.Alerts == nil {
if count == 0 {
return
}
metadata.Alerts = &AlertStatus{}
}
metadata.Alerts.RelevantCount = count
}
func variantForReport(id report.ID) string { func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday: case report.DailyToday:

View File

@@ -1,201 +0,0 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type Storm struct {
TimingWindow timeutil.Period `json:"timingWindow"`
EventHeadlines []string `json:"eventHeadlines,omitempty"`
Hazards []string `json:"hazards,omitempty"`
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"`
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
func BuildStorm(ctx BuildContext) (Package, error) {
if ctx.Resolved.Definition.ID != report.Storm {
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
}
if ctx.Bundle == nil {
return Package{}, fmt.Errorf("forecast bundle is required")
}
period := ctx.Resolved.ValidPeriod
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
summary := forecast.SummarizeDaypart("storm window", period, hourly)
summary.AlertOverlaps = alerts
storm := &Storm{
TimingWindow: period,
EventHeadlines: stormHeadlines(alerts),
Hazards: stormHazards(alerts, summary),
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
RelevantAlerts: alerts,
HourlyPeriods: hourly,
DailyPeriods: daily,
NarrativePeriods: narrative,
WindowSummary: summary,
Discussion: buildDiscussion(ctx.Bundle.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
pkg := buildPackage(ctx)
pkg.Storm = storm
setRelevantAlertCount(&pkg.Metadata, len(alerts))
return pkg, nil
}
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
var headlines []string
for _, alert := range alerts {
if alert.Headline != "" {
headlines = appendUnique(headlines, alert.Headline)
continue
}
if alert.Event != "" {
headlines = appendUnique(headlines, alert.Event)
}
}
if len(headlines) == 0 {
return []string{"No active alert headline overlaps the selected storm window."}
}
return headlines
}
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
hazards := map[string]struct{}{}
for _, alert := range alerts {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
for _, hazard := range hazardsForIndicators(summary.Indicators) {
hazards[hazard] = struct{}{}
}
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
hazards["precipitation"] = struct{}{}
}
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
hazards["wind"] = struct{}{}
}
out := sortedSet(hazards)
if len(out) == 0 {
return []string{"No storm-specific hazard signal stands out in the selected source data."}
}
return out
}
func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string {
var items []string
if summary.DominantCondition != "" {
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
}
if summary.PeakWindGust != nil {
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
}
for _, period := range narrative {
if period.TextDescription != "" {
items = append(items, "Narrative guidance: "+period.TextDescription)
break
}
}
if len(items) == 0 && len(hourly) > 0 {
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
}
if len(items) == 0 {
items = append(items, "No active storm signal is evident from the selected forecast window.")
}
return items
}
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
var items []string
for _, alert := range alerts {
label := alert.Event
if label == "" {
label = alert.Headline
}
if label != "" {
items = appendUnique(items, "Alert scenario to consider: "+label+".")
}
}
if summary.Indicators.Wind {
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
}
if summary.Indicators.Snow || summary.Indicators.Ice {
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
}
if len(items) == 0 {
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
}
return items
}
func stormConfidenceInputs(bundle *forecast.Bundle) []string {
var items []string
if bundle == nil {
return []string{"No source bundle was available for confidence context."}
}
if bundle.Discussion != nil {
items = appendUnique(items, bundle.Discussion.KeyMessages...)
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
items = appendUnique(items, "Short-term discussion is available for confidence context.")
}
}
if bundle.WeatherStory != nil {
if bundle.WeatherStory.Title != "" {
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
} else {
items = appendUnique(items, "Weather story source is available.")
}
}
for _, warning := range bundle.Warnings {
if warning.Code != "" {
items = appendUnique(items, "Source warning: "+warning.Code+".")
}
}
if len(items) == 0 {
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
}
return items
}
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string {
var items []string
if len(alerts) > 0 {
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, "Watch precipitation timing and probability trends.")
}
if summary.PeakWindGust != nil {
items = append(items, "Watch wind gust trends.")
}
if bundle != nil && bundle.Discussion != nil {
items = append(items, "Watch the next forecast discussion update for confidence changes.")
}
if len(items) == 0 {
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
}
return appendUnique(nil, items...)
}

View File

@@ -1,169 +0,0 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestStormBriefingWithActiveAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
precip := 80.0
gust := 42.0
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
TextDescription: "Severe thunderstorms and gusty wind",
ProbabilityOfPrecipitationPercent: &precip,
WindGustMph: &gust,
}}},
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Storms likely.",
}}},
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Damaging wind possible in stronger storms.",
}}},
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
}},
Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Storms may intensify quickly."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
},
WeatherStory: &forecast.WeatherStory{
OfficeID: "LSX",
StartTime: mustParse("2026-05-29T06:00:00Z"),
EndTime: mustParse("2026-05-29T18:00:00Z"),
Title: "Storm Risk",
Description: "Strong storms are possible.",
AltText: "Weather story graphic showing storm risk.",
Order: 1,
},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if pkg.Metadata.ReportID != report.Storm {
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
}
if pkg.Storm == nil {
t.Fatal("Storm = nil")
}
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
}
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
}
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
}
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
}
if pkg.Storm.WeatherStory == nil {
t.Fatal("WeatherStory = nil, want available story context")
}
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
}
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
}
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
}
if len(pkg.Storm.WhatToWatchNext) == 0 {
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
}
}
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
Alerts: &forecast.AlertRun{},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
}
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
}
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
}
if len(pkg.Storm.MostLikelyScenario) == 0 {
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
}
}
func TestStormBriefingQuietWindow(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
}
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
}
}

View File

@@ -5,120 +5,27 @@ import (
"math" "math"
"sort" "sort"
"strings" "strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
) )
type Daily struct {
BottomLine BottomLine `json:"bottomLine"`
Dayparts []forecast.DaypartSummary `json:"dayparts"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
Planning *TomorrowPlanning `json:"planning,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
ForecastSummaryDate string `json:"forecastSummaryDate"`
}
type BottomLine struct {
Summary string `json:"summary"`
Hazards []string `json:"hazards,omitempty"`
Temperature forecast.Range `json:"temperature,omitempty"`
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
}
type OutdoorWindows struct { type OutdoorWindows struct {
Best *OutdoorWindow `json:"best,omitempty"` Best *OutdoorWindow
Worst *OutdoorWindow `json:"worst,omitempty"` Worst *OutdoorWindow
} }
type OutdoorWindow struct { type OutdoorWindow struct {
Daypart string `json:"daypart"` Daypart string
Start string `json:"start"` Start string
End string `json:"end"` End string
Reasons []string `json:"reasons,omitempty"` Reasons []string
Score float64 `json:"score"` Score float64
} }
type TomorrowPlanning struct { type TomorrowPlanning struct {
MorningReadiness []string `json:"morningReadiness,omitempty"` MorningReadiness []string
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"` CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"` OvernightChangeWatch []string
}
type DiscussionContext struct {
Product string `json:"product,omitempty"`
KeyMessages []string `json:"keyMessages,omitempty"`
ShortTerm string `json:"shortTerm,omitempty"`
LongTerm string `json:"longTerm,omitempty"`
}
type WeatherStoryContext struct {
Available bool `json:"available"`
OfficeID string `json:"officeId,omitempty"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AltText string `json:"altText,omitempty"`
Priority bool `json:"priority"`
Order int `json:"order"`
DownloadURL string `json:"downloadUrl,omitempty"`
}
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
}
if summary == nil {
return Package{}, fmt.Errorf("daily forecast summary is required")
}
pkg := buildPackage(ctx)
pkg.Daily = &Daily{
BottomLine: buildBottomLine(summary),
Dayparts: summary.Dayparts,
RelevantAlerts: summary.AlertOverlaps,
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
NarrativePeriods: summary.NarrativePeriods,
Discussion: buildDiscussion(summary.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
ForecastSummaryDate: summary.Date,
}
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
pkg.Daily.Planning = buildTomorrowPlanning(summary)
}
return pkg, nil
}
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
bottomLine := BottomLine{}
conditions := map[string]struct{}{}
hazards := map[string]struct{}{}
for _, daypart := range summary.Dayparts {
addRange(&bottomLine.Temperature, daypart.Temperature)
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
hazards[hazard] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
bottomLine.Hazards = sortedSet(hazards)
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
return bottomLine
} }
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
@@ -250,51 +157,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
return nil return nil
} }
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
if discussion == nil {
return DiscussionContext{}
}
ctx := DiscussionContext{
Product: discussion.Product,
KeyMessages: discussion.KeyMessages,
}
if discussion.ShortTerm != nil {
ctx.ShortTerm = discussion.ShortTerm.Text
}
if discussion.LongTerm != nil {
ctx.LongTerm = discussion.LongTerm.Text
}
return ctx
}
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
if bundle == nil || bundle.WeatherStory == nil {
return nil
}
story := bundle.WeatherStory
return &WeatherStoryContext{
Available: true,
OfficeID: story.OfficeID,
StartTime: story.StartTime,
EndTime: story.EndTime,
UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title,
Description: story.Description,
AltText: story.AltText,
Priority: story.Priority,
Order: story.Order,
DownloadURL: story.DownloadURL,
}
}
func copyTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
copied := *value
return &copied
}
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow { func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
score := 0.0 score := 0.0
reasons := []string{} reasons := []string{}
@@ -335,20 +197,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
} }
} }
func bottomLineText(conditions []string, hazards []string) string {
if len(conditions) == 0 && len(hazards) == 0 {
return "Quiet weather is expected."
}
parts := []string{}
if len(conditions) > 0 {
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
}
if len(hazards) > 0 {
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
}
return strings.Join(parts, ". ") + "."
}
func hazardsForIndicators(indicators forecast.Indicators) []string { func hazardsForIndicators(indicators forecast.Indicators) []string {
var hazards []string var hazards []string
if indicators.Snow { if indicators.Snow {

View File

@@ -1,128 +0,0 @@
package briefing
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ThreeDay struct {
Days []OutlookDay `json:"days"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
type OutlookDay struct {
Date string `json:"date"`
Period timeutil.Period `json:"period"`
OverallCharacter string `json:"overallCharacter"`
Temperature forecast.Range `json:"temperature,omitempty"`
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
Risks []string `json:"risks,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
Dayparts []forecast.DaypartSummary `json:"dayparts"`
}
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
if ctx.Resolved.Definition.ID != report.ThreeDay {
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
}
if len(summaries) == 0 {
return Package{}, fmt.Errorf("3-day forecast summaries are required")
}
pkg := buildPackage(ctx)
pkg.ThreeDay = &ThreeDay{
Discussion: buildDiscussion(summaries[0].Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
for _, summary := range summaries {
day := buildOutlookDay(summary)
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
}
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
return pkg, nil
}
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
alerts := map[string]forecast.AlertOverlap{}
for _, day := range days {
for _, alert := range day.RelevantAlerts {
key := alert.Event
if key == "" {
key = alert.Headline
}
if key != "" {
alerts[key] = alert
}
}
}
keys := make([]string, 0, len(alerts))
for key := range alerts {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]forecast.AlertOverlap, 0, len(keys))
for _, key := range keys {
out = append(out, alerts[key])
}
return out
}
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
day := OutlookDay{
Date: summary.Date,
Period: summary.Period,
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
RelevantAlerts: summary.AlertOverlaps,
Dayparts: summary.Dayparts,
}
conditions := map[string]struct{}{}
risks := map[string]struct{}{}
for _, daypart := range summary.Dayparts {
addRange(&day.Temperature, daypart.Temperature)
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, risk := range hazardsForIndicators(daypart.Indicators) {
risks[risk] = struct{}{}
}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
risks["precipitation"] = struct{}{}
}
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
risks["wind"] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
risks[alert.Event] = struct{}{}
}
}
day.Risks = sortedSet(risks)
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
return day
}
func outlookCharacter(conditions []string, risks []string) string {
if len(conditions) == 0 && len(risks) == 0 {
return "Quiet weather is expected."
}
parts := []string{}
if len(conditions) > 0 {
parts = append(parts, strings.Join(conditions, "; "))
}
if len(risks) > 0 {
parts = append(parts, "risks: "+strings.Join(risks, "; "))
}
return strings.Join(parts, ". ") + "."
}

View File

@@ -1,94 +0,0 @@
package briefing
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
Now: mustParse("2026-05-29T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve 3-day: %v", err)
}
precip := 70.0
gust := 35.0
summaries := []forecast.DailySummary{
{
Date: "2026-05-29",
Period: timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00"),
End: mustParse("2026-05-30T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{
{
Name: "morning",
DominantCondition: "Showers and thunderstorms",
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: mustParse("2026-05-29T09:00:00-05:00"),
},
PeakWindGust: &forecast.TimedValue{
Value: gust,
Time: mustParse("2026-05-29T10:00:00-05:00"),
},
Indicators: forecast.Indicators{Wind: true},
},
},
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Unsettled stretch."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
},
},
{
Date: "2026-05-30",
Period: timeutil.Period{
Start: mustParse("2026-05-30T00:00:00-05:00"),
End: mustParse("2026-05-31T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}},
},
}
pkg, err := BuildThreeDay(BuildContext{
Resolved: resolved,
Units: "us",
Timezone: "America/Chicago",
}, summaries)
if err != nil {
t.Fatalf("BuildThreeDay() error = %v", err)
}
if pkg.Metadata.ReportID != report.ThreeDay {
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
}
if pkg.ThreeDay == nil {
t.Fatal("ThreeDay = nil")
}
if len(pkg.ThreeDay.Days) != 2 {
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
}
first := pkg.ThreeDay.Days[0]
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
t.Fatalf("first day = %#v, want conditions and risks", first)
}
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
}
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
}
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
}
}

View File

@@ -1,121 +0,0 @@
package briefing
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type Weekend struct {
Days []OutlookDay `json:"days"`
Planning WeekendPlanning `json:"planning"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
type WeekendPlanning struct {
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
RainStormTiming []string `json:"rainStormTiming,omitempty"`
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
}
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
if ctx.Resolved.Definition.ID != report.Weekend {
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
}
if len(summaries) == 0 {
return Package{}, fmt.Errorf("weekend forecast summaries are required")
}
pkg := buildPackage(ctx)
pkg.Weekend = &Weekend{
Discussion: buildDiscussion(summaries[0].Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
for _, summary := range summaries {
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
}
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
return pkg, nil
}
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning {
planning := WeekendPlanning{}
for _, day := range days {
if day.OutdoorWindows.Best != nil {
window := *day.OutdoorWindows.Best
window.Daypart = day.Date + " " + window.Daypart
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
}
if day.OutdoorWindows.Worst != nil {
window := *day.OutdoorWindows.Worst
window.Daypart = day.Date + " " + window.Daypart
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
}
for _, daypart := range day.Dayparts {
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
}
}
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
if discussion.ShortTerm != "" {
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
}
if discussion.LongTerm != "" {
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
}
if bundle != nil {
for _, warning := range bundle.Warnings {
if warning.Code != "" {
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
}
}
}
if len(planning.RainStormTiming) == 0 {
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
}
if len(planning.ComfortConcerns) == 0 {
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
}
if len(planning.UncertaintyInputs) == 0 {
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
}
return planning
}
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
notes := []string{}
label := weekendWindowLabel(date, daypart.Name)
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
}
return notes
}
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
notes := []string{}
label := weekendWindowLabel(date, daypart.Name)
if daypart.Indicators.Heat {
notes = append(notes, label+" heat may affect outdoor comfort.")
}
if daypart.Indicators.Cold {
notes = append(notes, label+" cold may affect outdoor comfort.")
}
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
}
return notes
}
func weekendWindowLabel(date string, daypart string) string {
if daypart == "" {
return date
}
return strings.TrimSpace(date + " " + daypart)
}

View File

@@ -1,99 +0,0 @@
package briefing
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
Now: mustParse("2026-05-29T08:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve weekend: %v", err)
}
precip := 70.0
gust := 32.0
summaries := []forecast.DailySummary{
{
Date: "2026-05-30",
Period: timeutil.Period{
Start: mustParse("2026-05-30T00:00:00-05:00"),
End: mustParse("2026-05-31T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{
{
Name: "afternoon",
DominantCondition: "Showers and thunderstorms",
Period: timeutil.Period{
Start: mustParse("2026-05-30T12:00:00-05:00"),
End: mustParse("2026-05-30T18:00:00-05:00"),
},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: mustParse("2026-05-30T15:00:00-05:00"),
},
PeakWindGust: &forecast.TimedValue{
Value: gust,
Time: mustParse("2026-05-30T16:00:00-05:00"),
},
Indicators: forecast.Indicators{Wind: true},
HourlyPeriods: []forecast.ForecastPeriod{
{
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
TextDescription: "Showers and thunderstorms",
},
},
},
},
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Timing may shift."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
},
},
}
pkg, err := BuildWeekend(BuildContext{
Resolved: resolved,
Units: "us",
Timezone: "America/Chicago",
}, summaries)
if err != nil {
t.Fatalf("BuildWeekend() error = %v", err)
}
if pkg.Metadata.ReportID != report.Weekend {
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
}
if pkg.Weekend == nil {
t.Fatal("Weekend = nil")
}
if len(pkg.Weekend.Days) != 1 {
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
}
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
}
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
}
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
t.Fatal("UncertaintyInputs length = 0, want discussion context")
}
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
}
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
}
}

View File

@@ -1,14 +1,16 @@
// Package changes compares structured briefing snapshots. // Package changes compares structured module snapshots.
package changes package changes
import ( import (
"fmt" "fmt"
"math" "math"
"sort" "sort"
"strconv"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type Thresholds struct { type Thresholds struct {
@@ -25,83 +27,168 @@ type Change struct {
Current string `json:"current,omitempty"` Current string `json:"current,omitempty"`
} }
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
if previous.Daily == nil { previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
return nil, fmt.Errorf("previous daily briefing is required") if err != nil {
return nil, fmt.Errorf("previous daily summary: %w", err)
} }
if current.Daily == nil { currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
return nil, fmt.Errorf("current daily briefing is required") if err != nil {
return nil, fmt.Errorf("current daily summary: %w", err)
} }
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("previous daypart summaries: %w", err)
}
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("current daypart summaries: %w", err)
}
previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest")
if err != nil {
return nil, err
}
currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest")
if err != nil {
return nil, err
}
previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing")
if err != nil {
return nil, err
}
currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing")
if err != nil {
return nil, err
}
var changes []Change var changes []Change
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...) changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...) changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...) changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...) if previousHasTiming && currentHasTiming {
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...) changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
}
changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...)
changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...)
changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...)
sortChanges(changes)
return changes, nil return changes, nil
} }
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change { type dailySummaryStanza struct {
var changes []Change Date string `json:"date,omitempty"`
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) { HighTempF *int `json:"high_temp_f,omitempty"`
changes = append(changes, Change{ LowTempF *int `json:"low_temp_f,omitempty"`
Type: "temperature_shift", MaxPopPercent *int `json:"max_pop_percent,omitempty"`
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min), MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Previous: fmt.Sprintf("%.0f", *previous.Min),
Current: fmt.Sprintf("%.0f", *current.Min),
})
}
if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) {
changes = append(changes, Change{
Type: "temperature_shift",
Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max),
Previous: fmt.Sprintf("%.0f", *previous.Max),
Current: fmt.Sprintf("%.0f", *current.Max),
})
}
return changes
} }
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change { type daypartSummaryStanza struct {
Period timeutil.Period `json:"period"`
TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
}
type precipTimingStanza struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
}
type alertDigestStanza struct {
Relevant []alertSummaryStanza `json:"relevant,omitempty"`
}
type alertSummaryStanza struct {
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
}
type indicators struct {
Snow bool
Ice bool
}
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, ok, err := module.StanzaValue[T](snapshot, name)
if err != nil {
return value, err
}
if !ok {
return value, fmt.Errorf("stanza %q is required", name)
}
return value, nil
}
func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change {
if previous == nil || current == nil { if previous == nil || current == nil {
return nil return nil
} }
var changes []Change if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
previousCategory := precipitationCategory(previous.Value)
currentCategory := precipitationCategory(current.Value)
if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) {
changes = append(changes, Change{
Type: "precip_probability_change",
Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory),
Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory),
Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory),
})
}
shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes()))
if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes {
changes = append(changes, Change{
Type: "precip_timing_shift",
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)),
Previous: clock(previous.Time),
Current: clock(current.Time),
})
}
return changes
}
func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change {
if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) {
return nil return nil
} }
return []Change{{ return []Change{{
Type: "wind_gust_change", Type: "temperature_shift",
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value), Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
Previous: fmt.Sprintf("%.0f mph", previous.Value), Previous: fmt.Sprintf("%d", *previous),
Current: fmt.Sprintf("%.0f mph", current.Value), Current: fmt.Sprintf("%d", *current),
}} }}
} }
func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change { func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change {
if previous == nil || current == nil {
return nil
}
previousCategory := precipitationCategory(float64(*previous))
currentCategory := precipitationCategory(float64(*current))
if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
return nil
}
changeType := prefix + "precip_probability_change"
return []Change{{
Type: changeType,
Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory),
Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory),
Current: fmt.Sprintf("%d%% %s", *current, currentCategory),
}}
}
func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change {
if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current {
return nil
}
previousTime, previousOK := parseClock(previous)
currentTime, currentOK := parseClock(current)
if !previousOK || !currentOK {
return nil
}
if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes {
return nil
}
return []Change{{
Type: prefix + "precip_timing_shift",
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current),
Previous: previous,
Current: current,
}}
}
func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change {
if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
return nil
}
return []Change{{
Type: prefix + "wind_gust_change",
Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current),
Previous: fmt.Sprintf("%d mph", *previous),
Current: fmt.Sprintf("%d mph", *current),
}}
}
func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change {
previousSet := alertSet(previous) previousSet := alertSet(previous)
currentSet := alertSet(current) currentSet := alertSet(current)
var changes []Change var changes []Change
@@ -119,7 +206,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
return changes return changes
} }
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change { func compareIndicators(previous indicators, current indicators, prefix string) []Change {
var changes []Change var changes []Change
for _, item := range []struct { for _, item := range []struct {
name string name string
@@ -132,7 +219,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
if item.previous == item.current { if item.previous == item.current {
continue continue
} }
changeType := item.name + "_risk_change" changeType := prefix + item.name + "_risk_change"
if item.current { if item.current {
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"}) changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
} else { } else {
@@ -142,16 +229,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
return changes return changes
} }
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators { func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
out := forecast.Indicators{} out := indicators{}
for _, daypart := range dayparts { for _, daypart := range dayparts {
out.Snow = out.Snow || daypart.Indicators.Snow out.Snow = out.Snow || daypart.Snow
out.Ice = out.Ice || daypart.Indicators.Ice out.Ice = out.Ice || daypart.Ice
} }
return out return out
} }
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} { func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
out := map[string]struct{}{} out := map[string]struct{}{}
for _, alert := range alerts { for _, alert := range alerts {
event := alert.Event event := alert.Event
@@ -185,10 +272,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
return math.Abs(current-previous) >= threshold return math.Abs(current-previous) >= threshold
} }
func clock(t time.Time) string {
return t.Format("15:04")
}
func sortChanges(items []Change) { func sortChanges(items []Change) {
sort.SliceStable(items, func(i, j int) bool { sort.SliceStable(items, func(i, j int) bool {
if items[i].Type == items[j].Type { if items[i].Type == items[j].Type {
@@ -197,3 +280,33 @@ func sortChanges(items []Change) {
return items[i].Type < items[j].Type return items[i].Type < items[j].Type
}) })
} }
func parseClock(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func parseTempRange(value string) (*int, *int) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parts := strings.Split(value, "-")
if len(parts) == 1 {
if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
return &parsed, &parsed
}
return nil, nil
}
minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0]))
maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
if minErr != nil || maxErr != nil {
return nil, nil
}
return &minValue, &maxValue
}

View File

@@ -1,16 +1,17 @@
package changes package changes
import ( import (
"strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func TestCompareDailyNoMeaningfulChanges(t *testing.T) { func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{}) current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -22,8 +23,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
} }
func TestCompareDailyTemperatureThreshold(t *testing.T) { func TestCompareDailyTemperatureThreshold(t *testing.T) {
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -35,8 +36,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) {
} }
func TestCompareDailyPrecipTimingShift(t *testing.T) { func TestCompareDailyPrecipTimingShift(t *testing.T) {
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{}) current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -48,8 +49,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) {
} }
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) { func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{}) current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -61,8 +62,8 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
} }
func TestCompareDailyIndicatorChange(t *testing.T) { func TestCompareDailyIndicatorChange(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true}) current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
@@ -73,26 +74,48 @@ func TestCompareDailyIndicatorChange(t *testing.T) {
} }
} }
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package { func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts)) _, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds())
if err == nil {
t.Fatal("CompareDaily() error = nil, want missing stanza error")
}
if !strings.Contains(err.Error(), "derived_daily_summary") {
t.Fatalf("error = %q, want derived_daily_summary context", err.Error())
}
}
func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot {
t.Helper()
relevant := make([]alertSummaryStanza, 0, len(alerts))
for _, alert := range alerts { for _, alert := range alerts {
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert}) relevant = append(relevant, alertSummaryStanza{Event: alert})
} }
return briefing.Package{ return snapshot(t,
Daily: &briefing.Daily{ module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
BottomLine: briefing.BottomLine{ Date: "2026-05-29",
Temperature: forecast.Range{Min: &low, Max: &high}, HighTempF: &high,
MaxPrecipProbability: &forecast.TimedValue{ LowTempF: &low,
Value: precip, MaxPopPercent: &precip,
Time: precipTime, }},
}, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
}, "morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow},
RelevantAlerts: alertOverlaps, }},
Dayparts: []forecast.DaypartSummary{ module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
{Name: "morning", Indicators: indicators}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
}, )
},
} }
func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot(outputs)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func period(start string, end string) timeutil.Period {
return timeutil.Period{Start: at(start), End: at(end)}
} }
func testThresholds() Thresholds { func testThresholds() Thresholds {

View File

@@ -2,77 +2,147 @@ package changes
import ( import (
"fmt" "fmt"
"time" "sort"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
if previous.ThreeDay == nil { previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
return nil, fmt.Errorf("previous 3-day briefing is required") if err != nil {
return nil, fmt.Errorf("previous daypart summaries: %w", err)
} }
if current.ThreeDay == nil { currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
return nil, fmt.Errorf("current 3-day briefing is required") if err != nil {
return nil, fmt.Errorf("current daypart summaries: %w", err)
} }
previousDays := outlookDaysByDate(previous.ThreeDay.Days) previousDays := outlookDaysFromDayparts(previousDayparts)
currentDays := outlookDaysByDate(current.ThreeDay.Days) currentDays := outlookDaysFromDayparts(currentDayparts)
return compareOutlookDays(previousDays, currentDays, thresholds, "")
}
type outlookDay struct {
Date string
LowTempF *int
HighTempF *int
MaxPopPercent *int
MaxPopTime string
MaxWindGustMph *int
Indicators indicators
}
func compareOutlookDays(previousDays map[string]outlookDay, currentDays map[string]outlookDay, thresholds Thresholds, prefix string) ([]Change, error) {
var changes []Change var changes []Change
for date, currentDay := range currentDays { for date, currentDay := range currentDays {
previousDay, ok := previousDays[date] previousDay, ok := previousDays[date]
if !ok { if !ok {
changes = append(changes, Change{Type: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date}) changes = append(changes, Change{Type: prefix + "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
continue continue
} }
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...) changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
} }
for date := range previousDays { for date := range previousDays {
if _, ok := currentDays[date]; !ok { if _, ok := currentDays[date]; !ok {
changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date}) changes = append(changes, Change{Type: prefix + "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
} }
} }
sortChanges(changes) sortChanges(changes)
return changes, nil return changes, nil
} }
func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change { func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change {
var changes []Change var changes []Change
for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) { for _, change := range compareTemperatureValues("Low", previous.LowTempF, current.LowTempF, thresholds.TemperatureDegrees) {
change.Message = date + ": " + change.Message
change.Type = prefix + "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range compareTemperatureValues("High", previous.HighTempF, current.HighTempF, thresholds.TemperatureDegrees) {
change.Message = date + ": " + change.Message
change.Type = prefix + "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range comparePrecipitationValues(previous.MaxPopPercent, current.MaxPopPercent, thresholds.PrecipProbabilityPoints, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
changes = append(changes, change) changes = append(changes, change)
} }
for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) { for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
changes = append(changes, change) changes = append(changes, change)
changes[len(changes)-1].Type = "outlook_" + change.Type
} }
for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) { for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change) changes = append(changes, change)
} }
for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) { for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") {
change.Message = date + ": " + change.Message change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) {
change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change) changes = append(changes, change)
} }
return changes return changes
} }
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay { func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
out := map[string]briefing.OutlookDay{} out := map[string]outlookDay{}
for _, day := range days { var keys []string
date := day.Date for key := range dayparts {
if date == "" && !day.Period.Start.IsZero() { keys = append(keys, key)
date = day.Period.Start.Format(time.DateOnly)
} }
if date != "" { sort.Strings(keys)
for _, key := range keys {
daypart := dayparts[key]
date := daypartDate(daypart)
if date == "" {
continue
}
day := out[date]
day.Date = date
low, high := parseTempRange(daypart.TempRangeF)
day.LowTempF = minInt(day.LowTempF, low)
day.HighTempF = maxInt(day.HighTempF, high)
day.MaxPopPercent = maxInt(day.MaxPopPercent, daypart.MaxPopPercent)
if daypart.MaxPopPercent != nil && day.MaxPopPercent != nil && *daypart.MaxPopPercent == *day.MaxPopPercent {
day.MaxPopTime = daypart.MaxPopTime
}
day.MaxWindGustMph = maxInt(day.MaxWindGustMph, daypart.MaxWindGustMph)
day.Indicators.Snow = day.Indicators.Snow || daypart.Snow
day.Indicators.Ice = day.Indicators.Ice || daypart.Ice
out[date] = day out[date] = day
} }
}
return out return out
} }
func daypartDate(daypart daypartSummaryStanza) string {
if !daypart.Period.Start.IsZero() {
return daypart.Period.Start.Format(timeutil.DateLayout)
}
return ""
}
func minInt(a *int, b *int) *int {
if a == nil {
return copyInt(b)
}
if b != nil && *b < *a {
return copyInt(b)
}
return a
}
func maxInt(a *int, b *int) *int {
if a == nil {
return copyInt(b)
}
if b != nil && *b > *a {
return copyInt(b)
}
return a
}
func copyInt(value *int) *int {
if value == nil {
return nil
}
copied := *value
return &copied
}

View File

@@ -2,41 +2,13 @@ package changes
import ( import (
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
) )
func TestCompareThreeDayDetectsDayChanges(t *testing.T) { func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
previousTemp := 70.0 previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
currentTemp := 78.0 current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
previousPrecip := 20.0
currentPrecip := 70.0
previous := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
Date: "2026-05-29",
Temperature: forecast.Range{Max: &previousTemp},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: previousPrecip,
Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC),
},
}}},
}
current := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
Date: "2026-05-29",
Temperature: forecast.Range{Max: &currentTemp},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: currentPrecip,
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
}}},
}
changes, err := CompareThreeDay(previous, current, Thresholds{ changes, err := CompareThreeDay(previous, current, Thresholds{
TemperatureDegrees: 5, TemperatureDegrees: 5,
@@ -50,17 +22,20 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
if len(changes) == 0 { if len(changes) == 0 {
t.Fatal("changes length = 0, want detected 3-day changes") t.Fatal("changes length = 0, want detected 3-day changes")
} }
var foundPrecip bool if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
var foundSnow bool
for _, change := range changes {
if change.Type == "outlook_precip_probability_change" {
foundPrecip = true
}
if change.Type == "outlook_snow_risk_change" {
foundSnow = true
}
}
if !foundPrecip || !foundSnow {
t.Fatalf("changes = %#v, want precipitation and snow changes", changes) t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
} }
} }
func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, precipTime string, snow bool) module.Snapshot {
t.Helper()
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
date + "_morning": {
Period: period(date+"T06:00:00Z", date+"T10:00:00Z"),
TempRangeF: tempRange,
MaxPopPercent: &precip,
MaxPopTime: precipTime,
Snow: snow,
},
}})
}

View File

@@ -3,24 +3,17 @@ package changes
import ( import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
) )
func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
if previous.Weekend == nil { previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
return nil, fmt.Errorf("previous weekend briefing is required")
}
if current.Weekend == nil {
return nil, fmt.Errorf("current weekend briefing is required")
}
previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}}
currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}}
changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
} }
for i := range changes { currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
changes[i].Type = "weekend_" + changes[i].Type if err != nil {
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
} }
return changes, nil return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
} }

View File

@@ -1,31 +1,10 @@
package changes package changes
import ( import "testing"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) { func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
previousTemp := 70.0 previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
currentTemp := 78.0 current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
previous := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.Weekend},
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
Date: "2026-05-30",
Temperature: forecast.Range{Max: &previousTemp},
}}},
}
current := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.Weekend},
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
Date: "2026-05-30",
Temperature: forecast.Range{Max: &currentTemp},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
}}},
}
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5}) changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
if err != nil { if err != nil {
@@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
if len(changes) == 0 { if len(changes) == 0 {
t.Fatal("changes length = 0, want weekend changes") t.Fatal("changes length = 0, want weekend changes")
} }
for _, change := range changes { if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
if change.Type == "weekend_outlook_snow_risk_change" {
return
}
}
t.Fatalf("changes = %#v, want snow risk change", changes) t.Fatalf("changes = %#v, want snow risk change", changes)
} }
}

View File

@@ -26,7 +26,7 @@ Usage:
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter inspect reports [--config PATH] [--limit N] weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect briefing [--config PATH] RUN_ID weatherreporter inspect modules [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID
@@ -118,8 +118,8 @@ var inspectRunCommands = []inspectRunCommand{
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { {Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectMetadata(ctx, req) return app.InspectMetadata(ctx, req)
}}, }},
{Name: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { {Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectBriefing(ctx, req) return app.InspectModules(ctx, req)
}}, }},
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { {Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectDataPackage(ctx, req) return app.InspectDataPackage(ctx, req)

View File

@@ -28,6 +28,10 @@ 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())
} }
removedInspectCommand := "inspect " + "briefing"
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
}
} }
func TestRunHelpShortFlag(t *testing.T) { func TestRunHelpShortFlag(t *testing.T) {
@@ -86,12 +90,12 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json") dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath) data, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) { if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") {
t.Fatalf("data package output missing storm content:\n%s", string(data)) t.Fatalf("data package output missing storm content:\n%s", string(data))
} }
} }
@@ -122,12 +126,12 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json") dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath) data, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) { if !strings.Contains(string(data), "id: daily_tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
t.Fatalf("data package output missing tomorrow content:\n%s", string(data)) t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
} }
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
@@ -160,7 +164,7 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -206,7 +210,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -217,7 +221,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) { if !strings.Contains(string(data), "id: three_day") || !strings.Contains(string(data), "derived_daypart_summaries:") {
t.Fatalf("data package output missing 3-day content:\n%s", string(data)) t.Fatalf("data package output missing 3-day content:\n%s", string(data))
} }
} }
@@ -252,7 +256,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -263,7 +267,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `"weekend"`) || !strings.Contains(string(data), `"planning"`) { if !strings.Contains(string(data), "id: weekend") || !strings.Contains(string(data), "derived_daypart_summaries:") {
t.Fatalf("data package output missing weekend content:\n%s", string(data)) t.Fatalf("data package output missing weekend content:\n%s", string(data))
} }
} }
@@ -289,7 +293,7 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob weekend packages: %v", err) t.Fatalf("glob weekend packages: %v", err)
} }
@@ -333,11 +337,11 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") { if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String()) t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
} }
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob daily packages: %v", err) t.Fatalf("glob daily packages: %v", err)
} }
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob weekend packages: %v", err) t.Fatalf("glob weekend packages: %v", err)
} }
@@ -589,11 +593,11 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json")) dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob daily packages: %v", err) t.Fatalf("glob daily packages: %v", err)
} }
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json")) threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob 3-day packages: %v", err) t.Fatalf("glob 3-day packages: %v", err)
} }
@@ -634,7 +638,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
@@ -645,26 +649,15 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) { if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily_today") {
t.Fatalf("data package output missing expected content:\n%s", string(data)) t.Fatalf("data package output missing expected content:\n%s", string(data))
} }
var decoded struct { if !strings.Contains(string(data), "location:") ||
Briefing struct { !strings.Contains(string(data), "id: home") ||
Metadata struct { !strings.Contains(string(data), "name: Brentwood") ||
Location struct { !strings.Contains(string(data), "region: St. Louis Metro") ||
ID string `json:"id"` !strings.Contains(string(data), "timezone: UTC") {
Name string `json:"name"` t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
Region string `json:"region"`
Timezone string `json:"timezone"`
} `json:"location"`
} `json:"metadata"`
} `json:"briefing"`
}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("decode data package: %v", err)
}
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
} }
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")) preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
if err != nil { if err != nil {
@@ -711,14 +704,14 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run(generate) error = %v", err) t.Fatalf("Run(generate) error = %v", err)
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil { if err != nil {
t.Fatalf("glob data package: %v", err) t.Fatalf("glob data package: %v", err)
} }
if len(dataPackageMatches) != 1 { if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches) t.Fatalf("data package files = %#v, want one", dataPackageMatches)
} }
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json") runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml")
stdout.Reset() stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr) err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
@@ -729,7 +722,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
t.Fatalf("inspect reports output missing run:\n%s", stdout.String()) t.Fatalf("inspect reports output missing run:\n%s", stdout.String())
} }
for _, command := range []string{"metadata", "briefing", "data-package", "sources"} { var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset() stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr) err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
if err != nil { if err != nil {
@@ -738,9 +732,12 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
if !strings.Contains(stdout.String(), runID) { if !strings.Contains(stdout.String(), runID) {
t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String()) t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String())
} }
if command == "sources" {
sourcesOutput = stdout.String()
} }
if !strings.Contains(stdout.String(), `"warnings"`) { }
t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String()) if !strings.Contains(sourcesOutput, `"warnings"`) {
t.Fatalf("inspect sources output missing warnings:\n%s", sourcesOutput)
} }
} }
@@ -772,7 +769,7 @@ func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"} commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
for _, command := range commands { for _, command := range commands {
t.Run(command+" requires run id", func(t *testing.T) { t.Run(command+" requires run id", func(t *testing.T) {

View File

@@ -2,7 +2,13 @@
// precedence, and validation. // precedence, and validation.
package config package config
import "time" import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gopkg.in/yaml.v3"
)
type MissingSourcePolicy string type MissingSourcePolicy string
type NotifyFailurePolicy string type NotifyFailurePolicy string
@@ -25,6 +31,7 @@ type Config struct {
Workspace WorkspaceConfig `yaml:"workspace"` Workspace WorkspaceConfig `yaml:"workspace"`
Dayparts []DaypartConfig `yaml:"dayparts"` Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"` RecentChange RecentChangeConfig `yaml:"recent_change"`
Reports map[string]ReportConfig `yaml:"reports"`
} }
type WeatherAPIConfig struct { type WeatherAPIConfig struct {
@@ -96,3 +103,71 @@ type RecentChangeConfig struct {
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"` WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"` PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
} }
type ReportConfig struct {
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
deterministicModulesSet bool
}
type ModuleConfigItem struct {
ID module.ID `yaml:"id"`
Options any `yaml:"options,omitempty"`
}
func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("report entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "deterministic_modules":
if err := node.Decode(&c.DeterministicModules); err != nil {
return err
}
c.deterministicModulesSet = true
default:
return fmt.Errorf("unknown report entry field %q", key)
}
}
return nil
}
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
if value.Value == "" {
return fmt.Errorf("module id is required")
}
m.ID = module.ID(value.Value)
return nil
case yaml.MappingNode:
var sawID bool
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "id":
if err := node.Decode(&m.ID); err != nil {
return err
}
sawID = true
case "options":
var options any
if err := node.Decode(&options); err != nil {
return err
}
m.Options = options
default:
return fmt.Errorf("unknown module entry field %q", key)
}
}
if !sawID || m.ID == "" {
return fmt.Errorf("module id is required")
}
return nil
default:
return fmt.Errorf("module entry must be a string or mapping")
}
}

View File

@@ -6,6 +6,9 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
) )
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
@@ -116,6 +119,138 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
} }
} }
func TestLoadReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
daily:
deterministic_modules:
- metadata
- current_conditions
- id: area_forecast_discussion
options:
sections:
- short_term
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides := cfg.ReportModuleOverrides()
items := overrides[report.DailyToday]
if len(items) != 3 {
t.Fatalf("daily override length = %d, want 3", len(items))
}
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion {
t.Fatalf("daily override = %#v, want configured module order", items)
}
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options)
}
if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
}
}
func TestReportModuleOverrideValidation(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "UnknownReport",
yaml: `
reports:
moon:
deterministic_modules:
- metadata
`,
wantErr: "reports.moon",
},
{
name: "UnknownModule",
yaml: `
reports:
daily:
deterministic_modules:
- missing_module
`,
wantErr: `unknown module "missing_module"`,
},
{
name: "DuplicateModule",
yaml: `
reports:
daily:
deterministic_modules:
- metadata
- metadata
`,
wantErr: `duplicate module "metadata"`,
},
{
name: "IncompatibleModule",
yaml: `
reports:
daily:
deterministic_modules:
- storm_window_summary
`,
wantErr: `not compatible with report "daily_today"`,
},
{
name: "InvalidOptions",
yaml: `
reports:
daily:
deterministic_modules:
- id: metadata
options:
sections:
- short_term
`,
wantErr: "options are invalid",
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
daily:
deterministic_modules:
- metadata
daily_today:
deterministic_modules:
- current_conditions
`,
wantErr: "duplicates report override",
},
{
name: "UnknownReportField",
yaml: `
reports:
daily:
modules:
- metadata
`,
wantErr: `unknown report entry field "modules"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, tt.yaml))
if err == nil {
t.Fatal("LoadFile() error = nil, want validation error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
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 {
@@ -126,6 +261,15 @@ func TestExplicitMissingConfigReturnsError(t *testing.T) {
} }
} }
func writeConfig(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
return path
}
func TestInvalidConfigProducesActionableError(t *testing.T) { func TestInvalidConfigProducesActionableError(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.yml") path := filepath.Join(dir, "config.yml")

View File

@@ -65,5 +65,6 @@ func Defaults() Config {
WindGustMilesPerHour: 10, WindGustMilesPerHour: 10,
PrecipTimingShiftMinutes: 120, PrecipTimingShiftMinutes: 120,
}, },
Reports: map[string]ReportConfig{},
} }
} }

View File

@@ -35,6 +35,10 @@ func Load(opts LoadOptions) (Config, error) {
cfg.WeatherAPI.Timezone = opts.Timezone cfg.WeatherAPI.Timezone = opts.Timezone
} }
if err := normalizeReportModules(&cfg); err != nil {
return Config{}, err
}
if err := loadSecrets(cfg.Secrets); err != nil { if err := loadSecrets(cfg.Secrets); err != nil {
return Config{}, err return Config{}, err
} }
@@ -61,5 +65,8 @@ func mergeFile(cfg *Config, path string) error {
if cfg.MissingSource.Sources == nil { if cfg.MissingSource.Sources == nil {
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{} cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
} }
if cfg.Reports == nil {
cfg.Reports = map[string]ReportConfig{}
}
return nil return nil
} }

125
internal/config/reports.go Normal file
View File

@@ -0,0 +1,125 @@
package config
import (
"bytes"
"fmt"
"reflect"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gopkg.in/yaml.v3"
)
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem {
overrides := map[report.ID][]module.ConfigItem{}
for key, reportCfg := range cfg.Reports {
if !reportCfg.deterministicModulesSet {
continue
}
id, err := reportIDForConfigKey(key)
if err != nil {
continue
}
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for _, item := range reportCfg.DeterministicModules {
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
}
overrides[id] = items
}
return overrides
}
func normalizeReportModules(cfg *Config) error {
if cfg.Reports == nil {
cfg.Reports = map[string]ReportConfig{}
}
moduleRegistry, err := briefing.DefaultModuleRegistry()
if err != nil {
return fmt.Errorf("initialize module registry: %w", err)
}
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := reportIDForConfigKey(key)
if err != nil {
return err
}
if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
continue
}
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
reportCfg.DeterministicModules[i].Options = options
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
}
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
}
cfg.Reports[key] = reportCfg
}
return nil
}
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
if raw == nil {
return nil, nil
}
definition, err := registry.Lookup(id)
if err != nil {
return nil, err
}
if definition.DefaultOptions == nil {
return nil, fmt.Errorf("module %q does not accept options", id)
}
optionType := reflect.TypeOf(definition.DefaultOptions)
normalized, err := decodeKnownOptions(raw, optionType)
if err != nil {
return nil, fmt.Errorf("module %q options are invalid: %w", id, err)
}
return normalized, nil
}
func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
data, err := yaml.Marshal(raw)
if err != nil {
return nil, err
}
target := reflect.New(optionType)
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)
if err := decoder.Decode(target.Interface()); err != nil {
return nil, err
}
return target.Elem().Interface(), nil
}
func reportIDForConfigKey(key string) (report.ID, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
switch normalized {
case "daily", "daily_today":
return report.DailyToday, nil
case "tomorrow", "daily_tomorrow":
return report.DailyTomorrow, nil
case "three_day", "three_day_outlook":
return report.ThreeDay, nil
case "weekend", "weekend_outlook":
return report.Weekend, nil
case "storm", "storm_report":
return report.Storm, nil
default:
return "", fmt.Errorf("reports.%s is not a known report", key)
}
}

View File

@@ -9,6 +9,9 @@ import (
) )
func Validate(cfg Config) error { func Validate(cfg Config) error {
if err := normalizeReportModules(&cfg); err != nil {
return err
}
if cfg.WeatherAPI.BaseURL != "" { if cfg.WeatherAPI.BaseURL != "" {
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL) parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {

141
internal/facts/facts.go Normal file
View File

@@ -0,0 +1,141 @@
// Package facts defines collected and derived report facts.
package facts
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type CollectedFacts struct {
FetchedAt time.Time
Observation *weatherdata.Observation
Current *weatherdata.Current
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning
}
func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
if bundle == nil {
return CollectedFacts{}
}
return CollectedFacts{
FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation,
Current: bundle.Current,
Hourly: bundle.Hourly,
Narrative: bundle.Narrative,
Alerts: bundle.Alerts,
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
}
}
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: f.FetchedAt,
Observation: f.Observation,
Current: f.Current,
Hourly: f.Hourly,
Narrative: f.Narrative,
Alerts: f.Alerts,
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
}
}
type BuildDerivedRequest struct {
Resolved report.Resolved
Timezone string
Dayparts []forecast.DaypartDefinition
Collected CollectedFacts
}
type DerivedFacts struct {
ValidPeriodHourlyPeriods []weatherdata.ForecastPeriod
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap
DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming
StormWindowSummary *forecast.DaypartSummary
}
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
if len(f.DailySummaries) == 0 {
return nil
}
return &f.DailySummaries[0]
}
func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
if !req.Resolved.ValidPeriod.IsValid() {
return DerivedFacts{}, fmt.Errorf("resolved valid period is required")
}
location, err := timeutil.LoadLocation(req.Timezone)
if err != nil {
return DerivedFacts{}, fmt.Errorf("load report timezone %q: %w", req.Timezone, err)
}
bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod
derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
}
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil {
return DerivedFacts{}, err
}
derived.DailySummaries = []forecast.DailySummary{*summary}
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts)
if err != nil {
return DerivedFacts{}, err
}
derived.DailySummaries = summaries
case report.Storm:
summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods)
summary.AlertOverlaps = derived.AlertOverlaps
derived.StormWindowSummary = &summary
default:
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
}
derived.DaypartSummaries = collectDaypartSummaries(derived)
return derived, nil
}
func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
var out []forecast.DaypartSummary
for _, summary := range derived.DailySummaries {
out = append(out, summary.Dayparts...)
}
if derived.StormWindowSummary != nil {
out = append(out, *derived.StormWindowSummary)
}
return out
}

View File

@@ -0,0 +1,247 @@
package facts
import (
"encoding/json"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
fetchedAt := mustParse("2026-05-29T10:00:00Z")
bundle := &weatherdata.Bundle{
FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
}
collected := BuildCollected(bundle)
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
}
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
}
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" {
t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings)
}
bundle.Sources[0].Name = "changed"
bundle.Warnings[0].Source = "changed"
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
}
}
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 4 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4", len(derived.ValidPeriodHourlyPeriods))
}
if len(derived.ValidPeriodNarrativePeriods) != 1 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 1", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want Flood Watch overlap", derived.AlertOverlaps)
}
if len(derived.DailySummaries) != 1 || len(derived.DailySummaries[0].Dayparts) != 3 {
t.Fatalf("DailySummaries = %#v, want one summary with dayparts", derived.DailySummaries)
}
if len(derived.DaypartSummaries) != 3 {
t.Fatalf("DaypartSummaries length = %d, want 3", len(derived.DaypartSummaries))
}
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 valid-period rain start", derived.PrecipTiming.FirstPrecipitation)
}
if !derived.PrecipTiming.ThunderMentioned {
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
}
morning := derived.DailySummaries[0].Dayparts[0]
if len(morning.HourlyPeriods) != 1 || morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 60 {
t.Fatalf("morning summary = %#v, want sliced hour with precip max", morning)
}
}
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location)
bundle.Narrative = nil
bundle.Alerts = nil
bundle.Discussion = nil
bundle.WeatherStory = nil
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.DailySummaries) != 3 {
t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries))
}
if len(derived.ValidPeriodNarrativePeriods) != 0 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.AlertOverlaps) != 0 {
t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps)
}
if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339))
}
}
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
location := testLocation()
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} {
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err != nil {
t.Fatalf("BuildDerived(%s) error = %v", id, err)
}
if len(derived.DailySummaries) == 0 {
t.Fatalf("BuildDerived(%s) DailySummaries length = 0, want summaries", id)
}
}
}
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
location := testLocation()
resolved := resolveStormForTest(t, location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 2 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods))
}
if len(derived.ValidPeriodDailyPeriods) != 1 {
t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods))
}
if derived.StormWindowSummary == nil {
t.Fatal("StormWindowSummary = nil, want summary")
}
if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 {
t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary)
}
if len(derived.StormWindowSummary.AlertOverlaps) != 1 {
t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps))
}
}
func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
Current: &weatherdata.Current{ConditionText: "Cloudy"},
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, 10),
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 80, 35),
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Heavy rain", 70, 25),
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot", 10, 15),
hour(location, "2026-05-30T09:00:00-05:00", "2026-05-30T10:00:00-05:00", "Clear", 0, 5),
}},
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storm chances peak midday.", 70, 20),
}},
Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00", "Storms", 70, 20),
}},
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
}},
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storm confidence is moderate."}},
WeatherStory: &weatherdata.WeatherStory{Title: "Storm Risk"},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
}
}
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
temperature := 70.0
return weatherdata.ForecastPeriod{
StartTime: mustParse(start).In(location),
EndTime: mustParse(end).In(location),
TextDescription: text,
TemperatureF: &temperature,
ProbabilityOfPrecipitationPercent: &precip,
WindGustMph: &gust,
}
}
func testDayparts() []forecast.DaypartDefinition {
return []forecast.DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
}
}
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("resolve %s: %v", id, err)
}
return resolved
}
func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T08:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T11:30:00-05:00"),
StormEnd: mustParse("2026-05-29T13:30:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
return resolved
}
func testLocation() *time.Location {
location, err := time.LoadLocation("America/Chicago")
if err != nil {
panic(err)
}
return location
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DaypartDefinition struct { type DaypartDefinition struct {
@@ -48,6 +49,6 @@ func ResolveDayparts(date time.Time, location *time.Location, definitions []Dayp
return windows, nil return windows, nil
} }
func PeriodForForecastPeriod(period ForecastPeriod) timeutil.Period { func PeriodForForecastPeriod(period weatherdata.ForecastPeriod) timeutil.Period {
return timeutil.Period{Start: period.StartTime, End: period.EndTime} return timeutil.Period{Start: period.StartTime, End: period.EndTime}
} }

View File

@@ -8,23 +8,24 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DailySummary struct { type DailySummary struct {
Date string `json:"date"` Date string `json:"date"`
Period timeutil.Period `json:"period"` Period timeutil.Period `json:"period"`
Dayparts []DaypartSummary `json:"dayparts"` Dayparts []DaypartSummary `json:"dayparts"`
NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"` NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"` AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"` Discussion *weatherdata.Discussion `json:"discussion,omitempty"`
SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []Source `json:"sourceProvenance,omitempty"` SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"`
} }
type DaypartSummary struct { type DaypartSummary struct {
Name string `json:"name"` Name string `json:"name"`
Period timeutil.Period `json:"period"` Period timeutil.Period `json:"period"`
HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"` HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods"`
Temperature Range `json:"temperature,omitempty"` Temperature Range `json:"temperature,omitempty"`
ApparentTemperature Range `json:"apparentTemperature,omitempty"` ApparentTemperature Range `json:"apparentTemperature,omitempty"`
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"` MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
@@ -64,7 +65,39 @@ type AlertOverlap struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { type PrecipTiming struct {
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
ThunderMentioned bool `json:"thunderMentioned,omitempty"`
}
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
var timing PrecipTiming
for _, forecastPeriod := range periods {
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
if forecastPeriod.ProbabilityOfPrecipitationPercent != nil && *forecastPeriod.ProbabilityOfPrecipitationPercent > 0 {
value := TimedValue{
Value: *forecastPeriod.ProbabilityOfPrecipitationPercent,
Time: forecastPeriod.StartTime,
}
if timing.FirstPrecipitation == nil || value.Time.Before(timing.FirstPrecipitation.Time) {
copied := value
timing.FirstPrecipitation = &copied
}
if timing.LastPrecipitation == nil || value.Time.After(timing.LastPrecipitation.Time) {
copied := value
timing.LastPrecipitation = &copied
}
}
if mentionsThunder(forecastPeriod.TextDescription) {
timing.ThunderMentioned = true
}
}
return timing
}
func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil { if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required") return nil, fmt.Errorf("forecast bundle is required")
} }
@@ -99,7 +132,7 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
return summary, nil return summary, nil
} }
func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) { func BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
if !period.IsValid() { if !period.IsValid() {
return nil, fmt.Errorf("valid forecast period is required") return nil, fmt.Errorf("valid forecast period is required")
} }
@@ -121,7 +154,7 @@ func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location
return summaries, nil return summaries, nil
} }
func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil { if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required") return nil, fmt.Errorf("forecast bundle is required")
} }
@@ -155,11 +188,11 @@ func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location
return summary, nil return summary, nil
} }
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod { func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
if run == nil { if run == nil {
return nil return nil
} }
var selected []ForecastPeriod var selected []weatherdata.ForecastPeriod
for _, forecastPeriod := range run.Periods { for _, forecastPeriod := range run.Periods {
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) { if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
selected = append(selected, forecastPeriod) selected = append(selected, forecastPeriod)
@@ -171,21 +204,21 @@ func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPer
return selected return selected
} }
func SelectNarrativePeriods(bundle *Bundle, period timeutil.Period) []ForecastPeriod { func SelectNarrativePeriods(bundle *weatherdata.Bundle, period timeutil.Period) []weatherdata.ForecastPeriod {
if bundle == nil || bundle.Narrative == nil { if bundle == nil || bundle.Narrative == nil {
return nil return nil
} }
return SelectHourlyPeriods(bundle.Narrative, period) return SelectHourlyPeriods(bundle.Narrative, period)
} }
func SelectDiscussion(bundle *Bundle) *Discussion { func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
if bundle == nil { if bundle == nil {
return nil return nil
} }
return bundle.Discussion return bundle.Discussion
} }
func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPeriod) DaypartSummary { func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.ForecastPeriod) DaypartSummary {
summary := DaypartSummary{ summary := DaypartSummary{
Name: name, Name: name,
Period: period, Period: period,
@@ -215,7 +248,7 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPer
return summary return summary
} }
func periodTemperatureValues(period ForecastPeriod) []*float64 { func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 {
values := []*float64{} values := []*float64{}
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...) values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...) values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
@@ -297,7 +330,11 @@ func indicatorsForText(text string) Indicators {
} }
} }
func numericIndicators(period ForecastPeriod) Indicators { func mentionsThunder(text string) bool {
return strings.Contains(strings.ToLower(text), "thunder")
}
func numericIndicators(period weatherdata.ForecastPeriod) Indicators {
windGust := firstValue(period.WindGustMph, period.WindGustKmh) windGust := firstValue(period.WindGustMph, period.WindGustKmh)
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh) windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
indicators := Indicators{} indicators := Indicators{}
@@ -325,7 +362,7 @@ func mergeIndicators(left Indicators, right Indicators) Indicators {
} }
} }
func AlertOverlaps(alertRun *AlertRun, period timeutil.Period) []AlertOverlap { func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []AlertOverlap {
if alertRun == nil { if alertRun == nil {
return nil return nil
} }

View File

@@ -8,6 +8,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) { func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
@@ -72,7 +73,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read fixture bundle: %v", err) t.Fatalf("read fixture bundle: %v", err)
} }
var bundle Bundle var bundle weatherdata.Bundle
if err := json.Unmarshal(data, &bundle); err != nil { if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode fixture bundle: %v", err) t.Fatalf("decode fixture bundle: %v", err)
} }
@@ -98,7 +99,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
func TestOvernightGroupingAcrossMidnight(t *testing.T) { func TestOvernightGroupingAcrossMidnight(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil), hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil), hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil), hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
@@ -122,7 +123,7 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) {
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) { func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location) date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil), hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil),
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil), hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil),
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil), hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil),
@@ -145,7 +146,7 @@ func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) { func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
location := time.UTC location := time.UTC
_, err := BuildDailySummary(&Bundle{}, time.Now(), location, []DaypartDefinition{ _, err := BuildDailySummary(&weatherdata.Bundle{}, time.Now(), location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"}, {Name: "morning", Start: "06:00", End: "12:00"},
}) })
if err == nil { if err == nil {
@@ -155,7 +156,7 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) { func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) location := time.FixedZone("Test", -5*60*60)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil), hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil), hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil), hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
@@ -191,7 +192,7 @@ func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
func TestAlertOverlap(t *testing.T) { func TestAlertOverlap(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) location := time.FixedZone("Test", -5*60*60)
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`) raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
alertRun := &AlertRun{Alerts: []json.RawMessage{raw}} alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{ period := timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00").In(location), Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
End: mustParse("2026-05-29T09:00:00-05:00").In(location), End: mustParse("2026-05-29T09:00:00-05:00").In(location),
@@ -209,6 +210,47 @@ func TestAlertOverlap(t *testing.T) {
} }
} }
func TestBuildPrecipTimingTracksRainAndThunder(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
periods := []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(30), nil, nil),
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 70, nil, ptr(80), nil, nil),
hour(location, "2026-05-29T18:00:00-05:00", "2026-05-29T19:00:00-05:00", "Dry", 70, nil, ptr(0), nil, nil),
}
timing := BuildPrecipTiming(periods)
if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
t.Fatalf("FirstPrecipitation = %#v, want 9 AM shower", timing.FirstPrecipitation)
}
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" {
t.Fatalf("LastPrecipitation = %#v, want noon thunderstorm", timing.LastPrecipitation)
}
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
}
if !timing.ThunderMentioned {
t.Fatal("ThunderMentioned = false, want true")
}
}
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
periods := []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Sunny", 70, nil, ptr(0), nil, nil),
}
timing := BuildPrecipTiming(periods)
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || timing.ThunderMentioned {
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
}
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
}
}
func TestThresholdHelpers(t *testing.T) { func TestThresholdHelpers(t *testing.T) {
if !DifferenceAtLeast(50, 56, 5) { if !DifferenceAtLeast(50, 56, 5) {
t.Fatal("DifferenceAtLeast = false, want true") t.Fatal("DifferenceAtLeast = false, want true")
@@ -221,31 +263,31 @@ func TestThresholdHelpers(t *testing.T) {
} }
} }
func testBundle(location *time.Location) *Bundle { func testBundle(location *time.Location) *weatherdata.Bundle {
return &Bundle{ return &weatherdata.Bundle{
Hourly: &ForecastRun{Periods: []ForecastPeriod{ Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil), hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil),
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)), hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)),
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)), hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)),
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)), hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)),
}}, }},
Narrative: &ForecastRun{Periods: []ForecastPeriod{ Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil), hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil),
}}, }},
Alerts: &AlertRun{Alerts: []json.RawMessage{ Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`), json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`),
}}, }},
Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}}, Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
Sources: []Source{{Name: "hourly"}}, Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}}, Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
} }
} }
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) ForecastPeriod { func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) weatherdata.ForecastPeriod {
startTime := mustParse(start).In(location) startTime := mustParse(start).In(location)
endTime := mustParse(end).In(location) endTime := mustParse(end).In(location)
temp := temperature temp := temperature
return ForecastPeriod{ return weatherdata.ForecastPeriod{
StartTime: startTime, StartTime: startTime,
EndTime: endTime, EndTime: endTime,
TextDescription: text, TextDescription: text,

148
internal/module/module.go Normal file
View File

@@ -0,0 +1,148 @@
// Package module defines stable module contracts for prompt-facing stanzas.
package module
import (
"encoding/json"
"fmt"
)
const SnapshotSchemaVersion = "weatherreporter.modules.v1"
type ID string
const (
Metadata ID = "metadata"
CurrentConditions ID = "current_conditions"
DerivedDailySummary ID = "derived_daily_summary"
DerivedDaypartSummaries ID = "derived_daypart_summaries"
HourlyTable ID = "hourly_table"
PrecipTiming ID = "precip_timing"
AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story"
ForecastDelta ID = "forecast_delta"
OutdoorWindows ID = "outdoor_windows"
TomorrowPlanning ID = "tomorrow_planning"
WeekendPlanning ID = "weekend_planning"
StormWindowSummary ID = "storm_window_summary"
)
type ConfigItem struct {
ID ID `json:"id"`
Options any `json:"options,omitempty"`
}
type Output struct {
ID ID `json:"id"`
StanzaName string `json:"stanzaName"`
Value any `json:"value"`
}
type Snapshot struct {
SchemaVersion string `json:"schemaVersion"`
Outputs []Output `json:"outputs"`
}
func NewSnapshot(outputs []Output) (Snapshot, error) {
snapshot := Snapshot{
SchemaVersion: SnapshotSchemaVersion,
Outputs: append([]Output(nil), outputs...),
}
if err := snapshot.Validate(); err != nil {
return Snapshot{}, err
}
return snapshot, nil
}
func (s Snapshot) Validate() error {
if s.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required")
}
seenModules := map[ID]struct{}{}
seenStanzas := map[string]struct{}{}
for i, output := range s.Outputs {
if output.ID == "" {
return fmt.Errorf("outputs[%d].id is required", i)
}
if output.StanzaName == "" {
return fmt.Errorf("outputs[%d].stanzaName is required", i)
}
if _, ok := seenModules[output.ID]; ok {
return fmt.Errorf("duplicate module output %q", output.ID)
}
seenModules[output.ID] = struct{}{}
if _, ok := seenStanzas[output.StanzaName]; ok {
return fmt.Errorf("duplicate stanza name %q", output.StanzaName)
}
seenStanzas[output.StanzaName] = struct{}{}
}
return nil
}
func (s Snapshot) LookupStanza(name string) (Output, bool) {
for _, output := range s.Outputs {
if output.StanzaName == name {
return output, true
}
}
return Output{}, false
}
func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
var zero T
output, ok := s.LookupStanza(name)
if !ok {
return zero, false, nil
}
data, err := json.Marshal(output.Value)
if err != nil {
return zero, true, fmt.Errorf("marshal stanza %q: %w", name, err)
}
if err := json.Unmarshal(data, &zero); err != nil {
return zero, true, fmt.Errorf("decode stanza %q: %w", name, err)
}
return zero, true, nil
}
type FactRequirement string
const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedStormWindowSummary FactRequirement = "derived.storm_window_summary"
)
type MissingDataBehavior string
const (
MissingDataOmit MissingDataBehavior = "omit"
MissingDataEmpty MissingDataBehavior = "empty"
MissingDataError MissingDataBehavior = "error"
MissingDataWarn MissingDataBehavior = "warn"
)
type MetadataOptions struct{}
type CurrentConditionsOptions struct{}
type DerivedDailySummaryOptions struct{}
type DerivedDaypartSummariesOptions struct{}
type HourlyTableOptions struct{}
type PrecipTimingOptions struct{}
type AlertDigestOptions struct{}
type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
}
type WeatherStoryOptions struct{}
type ForecastDeltaOptions struct{}
type OutdoorWindowsOptions struct{}
type TomorrowPlanningOptions struct{}
type WeekendPlanningOptions struct{}
type StormWindowSummaryOptions struct{}

View File

@@ -0,0 +1,84 @@
package module
import (
"encoding/json"
"strings"
"testing"
)
type testStanza struct {
Message string `json:"message"`
Count int `json:"count"`
}
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}},
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
if snapshot.SchemaVersion != SnapshotSchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", snapshot.SchemaVersion, SnapshotSchemaVersion)
}
if snapshot.Outputs[0].ID != Metadata || snapshot.Outputs[1].ID != AlertDigest {
t.Fatalf("Outputs order = %#v, want input order", snapshot.Outputs)
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
got := string(data)
want := `{"schemaVersion":"weatherreporter.modules.v1","outputs":[{"id":"metadata","stanzaName":"metadata","value":{"message":"first","count":1}},{"id":"alert_digest","stanzaName":"alert_digest","value":{"message":"second","count":2}}]}`
if got != want {
t.Fatalf("json = %s, want %s", got, want)
}
}
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
_, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: struct{}{}},
{ID: Metadata, StanzaName: "other_metadata", Value: struct{}{}},
})
if err == nil || !strings.Contains(err.Error(), `duplicate module output "metadata"`) {
t.Fatalf("duplicate module error = %v, want duplicate module output", err)
}
_, err = NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: struct{}{}},
{ID: CurrentConditions, StanzaName: "metadata", Value: struct{}{}},
})
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
t.Fatalf("duplicate stanza error = %v, want duplicate stanza name", err)
}
}
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
value, found, err := StanzaValue[testStanza](snapshot, "metadata")
if err != nil {
t.Fatalf("StanzaValue() error = %v", err)
}
if !found {
t.Fatal("StanzaValue() found = false, want true")
}
if value.Message != "available" || value.Count != 3 {
t.Fatalf("StanzaValue() = %#v, want decoded stanza", value)
}
_, found, err = StanzaValue[testStanza](snapshot, "missing")
if err != nil {
t.Fatalf("StanzaValue(missing) error = %v", err)
}
if found {
t.Fatal("StanzaValue(missing) found = true, want false")
}
}

View File

@@ -1,72 +1,93 @@
// Package promptinput builds prompt data packages from briefing packages. // Package promptinput builds prompt data packages from module snapshots.
package promptinput package promptinput
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"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/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
"gopkg.in/yaml.v3"
) )
const SchemaVersion = "weatherreporter.data_package.v1" const SchemaVersion = "weatherreporter.data_package.v2"
type BuildRequest struct {
Metadata Metadata
Modules module.Snapshot
RecentChanges []changes.Change
}
type Metadata struct {
RunID string
ReportID report.ID
Variant string
PromptID string
GeneratedAt time.Time
Timezone string
ValidPeriod timeutil.Period
SourceWarnings []weatherdata.SourceWarning
}
type Package struct { type Package struct {
SchemaVersion string `json:"schemaVersion"` SchemaVersion string `json:"schemaVersion" yaml:"schema_version"`
RunID string `json:"runId"` RunID string `json:"runId" yaml:"run_id"`
Report Report `json:"report"` Report Report `json:"report" yaml:"report"`
Briefing briefing.Package `json:"briefing"` Briefing BriefingStanzas `json:"briefing" yaml:"briefing"`
RecentChanges RecentChanges `json:"recentChanges"` RecentChanges RecentChanges `json:"recentChanges" yaml:"recent_changes"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty" yaml:"source_warnings,omitempty"`
} }
type Report struct { type Report struct {
ID report.ID `json:"id"` ID report.ID `json:"id" yaml:"id"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty" yaml:"variant,omitempty"`
PromptID string `json:"promptId"` PromptID string `json:"promptId" yaml:"prompt_id"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt" yaml:"generated_at"`
Timezone string `json:"timezone"` Timezone string `json:"timezone" yaml:"timezone"`
CurrentLocalDate string `json:"currentLocalDate"` CurrentLocalDate string `json:"currentLocalDate" yaml:"current_local_date"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod" yaml:"valid_period"`
}
type BriefingStanzas struct {
Order []string `json:"-" yaml:"-"`
Values map[string]any `json:"-" yaml:"-"`
} }
type RecentChanges struct { type RecentChanges struct {
Items []changes.Change `json:"items"` Items []changes.Change `json:"items" yaml:"items"`
} }
func Build(briefingPackage briefing.Package) (Package, error) { func Build(req BuildRequest) (Package, error) {
return BuildWithRecentChanges(briefingPackage, nil) localDate, err := currentLocalDate(req.Metadata.GeneratedAt, req.Metadata.Timezone)
}
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
if err != nil { if err != nil {
return Package{}, err return Package{}, err
} }
items := make([]changes.Change, len(recentChanges)) items := make([]changes.Change, len(req.RecentChanges))
copy(items, recentChanges) copy(items, req.RecentChanges)
if items == nil { if items == nil {
items = []changes.Change{} items = []changes.Change{}
} }
pkg := Package{ pkg := Package{
SchemaVersion: SchemaVersion, SchemaVersion: SchemaVersion,
RunID: briefingPackage.Metadata.RunID, RunID: req.Metadata.RunID,
Report: Report{ Report: Report{
ID: briefingPackage.Metadata.ReportID, ID: req.Metadata.ReportID,
Variant: briefingPackage.Metadata.Variant, Variant: req.Metadata.Variant,
PromptID: briefingPackage.Metadata.PromptID, PromptID: req.Metadata.PromptID,
GeneratedAt: briefingPackage.Metadata.GeneratedAt, GeneratedAt: req.Metadata.GeneratedAt,
Timezone: briefingPackage.Metadata.Timezone, Timezone: req.Metadata.Timezone,
CurrentLocalDate: localDate, CurrentLocalDate: localDate,
ValidPeriod: briefingPackage.Metadata.ValidPeriod, ValidPeriod: req.Metadata.ValidPeriod,
}, },
Briefing: briefingPackage, Briefing: stanzasFromSnapshot(req.Modules),
RecentChanges: RecentChanges{Items: items}, RecentChanges: RecentChanges{Items: items},
SourceWarnings: briefingPackage.Metadata.SourceWarnings, SourceWarnings: append([]weatherdata.SourceWarning(nil), req.Metadata.SourceWarnings...),
} }
if err := Validate(pkg); err != nil { if err := Validate(pkg); err != nil {
return Package{}, err return Package{}, err
@@ -74,6 +95,16 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
return pkg, nil return pkg, nil
} }
func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
values := map[string]any{}
order := make([]string, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
order = append(order, output.StanzaName)
values[output.StanzaName] = output.Value
}
return BriefingStanzas{Order: order, Values: values}
}
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) { func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
location, err := timeutil.LoadLocation(timezone) location, err := timeutil.LoadLocation(timezone)
if err != nil { if err != nil {
@@ -86,6 +117,9 @@ func Validate(pkg Package) error {
if pkg.SchemaVersion == "" { if pkg.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required") return fmt.Errorf("schemaVersion is required")
} }
if pkg.SchemaVersion != SchemaVersion {
return fmt.Errorf("schemaVersion must be %s", SchemaVersion)
}
if pkg.RunID == "" { if pkg.RunID == "" {
return fmt.Errorf("runId is required") return fmt.Errorf("runId is required")
} }
@@ -107,33 +141,134 @@ func Validate(pkg Package) error {
if !pkg.Report.ValidPeriod.IsValid() { if !pkg.Report.ValidPeriod.IsValid() {
return fmt.Errorf("report.validPeriod must be valid") return fmt.Errorf("report.validPeriod must be valid")
} }
if pkg.Briefing.Metadata.RunID == "" { if len(pkg.Briefing.Order) == 0 {
return fmt.Errorf("briefing.metadata.runId is required") return fmt.Errorf("briefing stanzas are required")
} }
if pkg.Briefing.Metadata.RunID != pkg.RunID { for _, name := range pkg.Briefing.Order {
return fmt.Errorf("briefing.metadata.runId must match runId") if name == "" {
return fmt.Errorf("briefing stanza name is required")
} }
if pkg.Briefing.Metadata.SchemaVersion == "" { if _, ok := pkg.Briefing.Values[name]; !ok {
return fmt.Errorf("briefing.metadata.schemaVersion is required") return fmt.Errorf("briefing stanza %q is missing", name)
} }
if pkg.Briefing.Metadata.PromptID != pkg.Report.PromptID {
return fmt.Errorf("briefing.metadata.promptId must match report.promptId")
}
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
return fmt.Errorf("briefing.metadata.reportId must match report.id")
}
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil && pkg.Briefing.Storm == nil {
return fmt.Errorf("briefing report content is required")
} }
return nil return nil
} }
func Save(path string, pkg Package) error { func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil { data, err := MarshalYAML(pkg)
if err != nil {
return err return err
} }
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil { if err := fileutil.WriteFileAtomic(path, data); err != nil {
return fmt.Errorf("save data package: %w", err) return fmt.Errorf("save data package: %w", err)
} }
return nil return nil
} }
func MarshalYAML(pkg Package) ([]byte, error) {
if err := Validate(pkg); err != nil {
return nil, err
}
data, err := yaml.Marshal(pkg)
if err != nil {
return nil, fmt.Errorf("marshal data package: %w", err)
}
return data, nil
}
func LoadYAML(data []byte) (Package, error) {
var pkg Package
decoder := yaml.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&pkg); err != nil {
return Package{}, fmt.Errorf("decode data package: %w", err)
}
if err := Validate(pkg); err != nil {
return Package{}, err
}
return pkg, nil
}
func (b BriefingStanzas) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
for _, name := range b.Order {
value, ok := b.Values[name]
if !ok {
continue
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
valueNode, err := yamlNode(value)
if err != nil {
return nil, fmt.Errorf("marshal briefing stanza %q: %w", name, err)
}
node.Content = append(node.Content, keyNode, valueNode)
}
return node, nil
}
func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("briefing must be a mapping")
}
values := map[string]any{}
order := make([]string, 0, len(value.Content)/2)
for i := 0; i < len(value.Content); i += 2 {
name := value.Content[i].Value
var stanza any
if err := value.Content[i+1].Decode(&stanza); err != nil {
return err
}
order = append(order, name)
values[name] = stanza
}
b.Order = order
b.Values = values
return nil
}
func (b BriefingStanzas) MarshalJSON() ([]byte, error) {
out := map[string]any{}
for _, name := range b.Order {
if value, ok := b.Values[name]; ok {
out[name] = value
}
}
return json.Marshal(out)
}
func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
var values map[string]any
if err := json.Unmarshal(data, &values); err != nil {
return err
}
order := make([]string, 0, len(values))
for name := range values {
order = append(order, name)
}
b.Order = order
b.Values = values
return nil
}
func yamlNode(value any) (*yaml.Node, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var normalized any
if err := json.Unmarshal(data, &normalized); err != nil {
return nil, err
}
data, err = yaml.Marshal(normalized)
if err != nil {
return nil, err
}
var node yaml.Node
if err := yaml.Unmarshal(data, &node); err != nil {
return nil, err
}
if len(node.Content) == 0 {
return &yaml.Node{Kind: yaml.MappingNode}, nil
}
return node.Content[0], nil
}

View File

@@ -1,20 +1,17 @@
package promptinput package promptinput
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
"time" "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/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func TestBuildDailyDataPackage(t *testing.T) { func TestBuildDailyDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage() pkg, err := Build(validBuildRequest(t))
pkg, err := Build(briefingPackage)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -23,7 +20,7 @@ func TestBuildDailyDataPackage(t *testing.T) {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
} }
if pkg.RunID != "20260529T100000Z_daily_today" { if pkg.RunID != "20260529T100000Z_daily_today" {
t.Fatalf("RunID = %q, want briefing run id", pkg.RunID) t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
} }
if pkg.Report.PromptID != "weather.daily_report" { if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
@@ -31,14 +28,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
if pkg.Report.CurrentLocalDate != "2026-05-29" { if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
} }
if pkg.Briefing.Daily == nil { if pkg.Briefing.Order[0] != "metadata" || pkg.Briefing.Order[1] != "current_conditions" || pkg.Briefing.Order[2] != "derived_daily_summary" {
t.Fatal("Briefing.Daily = nil") t.Fatalf("Briefing.Order = %#v, want snapshot stanza order", pkg.Briefing.Order)
} }
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" { if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Partly cloudy" {
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location) t.Fatalf("current_conditions.condition_text = %q, want Partly cloudy", got)
}
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
} }
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 { if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items) t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
@@ -46,11 +40,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
} }
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) { func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage() req := validBuildRequest(t)
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC) req.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
briefingPackage.Metadata.Timezone = "America/Chicago" req.Metadata.Timezone = "America/Chicago"
pkg, err := Build(briefingPackage) pkg, err := Build(req)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -61,10 +55,10 @@ func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
} }
func TestBuildRejectsInvalidReportTimezone(t *testing.T) { func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage() req := validBuildRequest(t)
briefingPackage.Metadata.Timezone = "Not/AZone" req.Metadata.Timezone = "Not/AZone"
_, err := Build(briefingPackage) _, err := Build(req)
if err == nil { if err == nil {
t.Fatal("Build() error = nil, want invalid timezone error") t.Fatal("Build() error = nil, want invalid timezone error")
} }
@@ -74,7 +68,7 @@ func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
} }
func TestValidateRequiresFields(t *testing.T) { func TestValidateRequiresFields(t *testing.T) {
pkg, err := Build(validBriefingPackage()) pkg, err := Build(validBuildRequest(t))
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -90,7 +84,7 @@ func TestValidateRequiresFields(t *testing.T) {
} }
func TestValidateRequiresCurrentLocalDate(t *testing.T) { func TestValidateRequiresCurrentLocalDate(t *testing.T) {
pkg, err := Build(validBriefingPackage()) pkg, err := Build(validBuildRequest(t))
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -105,17 +99,17 @@ func TestValidateRequiresCurrentLocalDate(t *testing.T) {
} }
} }
func TestBuildThreeDayDataPackage(t *testing.T) { func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
briefingPackage := validBriefingPackage() req := validBuildRequest(t)
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day" req.Metadata.RunID = "20260529T100000Z_three_day"
briefingPackage.Metadata.ReportID = report.ThreeDay req.Metadata.ReportID = report.ThreeDay
briefingPackage.Metadata.PromptID = "weather.three_day_outlook" req.Metadata.PromptID = "weather.three_day_outlook"
briefingPackage.Daily = nil req.Modules = snapshotWithOutputs(t,
briefingPackage.ThreeDay = &briefing.ThreeDay{ module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
Days: []briefing.OutlookDay{{Date: "2026-05-29"}}, module.Output{ID: module.ForecastDelta, StanzaName: "three_day", Value: map[string]any{"days": []string{"2026-05-29"}}},
} )
pkg, err := Build(briefingPackage) pkg, err := Build(req)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
@@ -123,107 +117,88 @@ func TestBuildThreeDayDataPackage(t *testing.T) {
if pkg.Report.ID != report.ThreeDay { if pkg.Report.ID != report.ThreeDay {
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID) t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
} }
if pkg.Briefing.ThreeDay == nil { if _, ok := pkg.Briefing.Values["three_day"]; !ok {
t.Fatal("Briefing.ThreeDay = nil") t.Fatal("Briefing.Values[three_day] missing")
} }
} }
func TestBuildWeekendDataPackage(t *testing.T) { func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
briefingPackage := validBriefingPackage() pkg, err := Build(validBuildRequest(t))
briefingPackage.Metadata.RunID = "20260529T100000Z_weekend"
briefingPackage.Metadata.ReportID = report.Weekend
briefingPackage.Metadata.PromptID = "weather.weekend_outlook"
briefingPackage.Daily = nil
briefingPackage.Weekend = &briefing.Weekend{
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
}
pkg, err := Build(briefingPackage)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
if pkg.Report.ID != report.Weekend { first, err := MarshalYAML(pkg)
t.Fatalf("Report.ID = %q, want weekend", pkg.Report.ID)
}
if pkg.Briefing.Weekend == nil {
t.Fatal("Briefing.Weekend = nil")
}
}
func TestBuildStormDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_storm"
briefingPackage.Metadata.ReportID = report.Storm
briefingPackage.Metadata.PromptID = "weather.storm_report"
briefingPackage.Daily = nil
briefingPackage.Storm = &briefing.Storm{
TimingWindow: briefingPackage.Metadata.ValidPeriod,
Hazards: []string{"Thunderstorms"},
}
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.Storm {
t.Fatalf("Report.ID = %q, want storm", pkg.Report.ID)
}
if pkg.Report.PromptID != "weather.storm_report" {
t.Fatalf("PromptID = %q, want weather.storm_report", pkg.Report.PromptID)
}
if pkg.Briefing.Storm == nil {
t.Fatal("Briefing.Storm = nil")
}
}
func TestMarshalDeterministic(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
first, err := json.MarshalIndent(pkg, "", " ")
if err != nil { if err != nil {
t.Fatalf("first marshal: %v", err) t.Fatalf("first marshal: %v", err)
} }
second, err := json.MarshalIndent(pkg, "", " ") second, err := MarshalYAML(pkg)
if err != nil { if err != nil {
t.Fatalf("second marshal: %v", err) t.Fatalf("second marshal: %v", err)
} }
if string(first) != string(second) { if string(first) != string(second) {
t.Fatalf("JSON output changed between marshals:\n%s\n---\n%s", string(first), string(second)) t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
}
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(first), "briefing:\n") ||
!strings.Contains(string(first), " current_conditions:\n") ||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
t.Fatalf("YAML output missing expected named stanzas:\n%s", string(first))
} }
} }
func validBriefingPackage() briefing.Package { func TestLoadYAMLRoundTrip(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
loaded, err := LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
}
if loaded.Briefing.Order[1] != "current_conditions" {
t.Fatalf("loaded package order = %#v, want current_conditions second", loaded.Briefing.Order)
}
}
func validBuildRequest(t *testing.T) BuildRequest {
t.Helper()
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return briefing.Package{ return BuildRequest{
Metadata: briefing.Metadata{ Metadata: Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: "20260529T100000Z_daily_today", RunID: "20260529T100000Z_daily_today",
ReportID: report.DailyToday, ReportID: report.DailyToday,
Variant: "today",
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
Units: "us",
Timezone: "America/Chicago", Timezone: "America/Chicago",
Location: &briefing.LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
ValidPeriod: timeutil.Period{ ValidPeriod: timeutil.Period{
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC), Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC), End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
}, },
}, },
CurrentConditions: &briefing.CurrentConditionsContext{ Modules: snapshotWithOutputs(t,
ConditionText: "Partly cloudy", module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
}, module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
Daily: &briefing.Daily{ module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
ForecastSummaryDate: "2026-05-29", ),
},
} }
} }
func snapshotWithOutputs(t *testing.T, outputs ...module.Output) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot(outputs)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -42,6 +43,7 @@ type Definition struct {
BatchOutputName string BatchOutputName string
Generated bool Generated bool
CompatiblePriorIDs []ID CompatiblePriorIDs []ID
Modules []module.ConfigItem
Morning bool Morning bool
Evening bool Evening bool
resolve func(ResolveRequest) (timeutil.Period, error) resolve func(ResolveRequest) (timeutil.Period, error)
@@ -63,6 +65,14 @@ func (d Definition) CompatibleWithPrior(id ID) bool {
return false return false
} }
func (d Definition) ModuleIDs() []module.ID {
ids := make([]module.ID, 0, len(d.Modules))
for _, item := range d.Modules {
ids = append(ids, item.ID)
}
return ids
}
type ResolveRequest struct { type ResolveRequest struct {
Now time.Time Now time.Time
Location *time.Location Location *time.Location

View File

@@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -251,6 +252,140 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
} }
} }
func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
tests := []struct {
id ID
want []module.ID
}{
{
id: DailyToday,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
},
{
id: DailyTomorrow,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
},
},
{
id: ThreeDay,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
},
{
id: Weekend,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.WeekendPlanning,
},
},
{
id: Storm,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyTable,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.StormWindowSummary,
},
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if !reflect.DeepEqual(definition.ModuleIDs(), tt.want) {
t.Fatalf("ModuleIDs() = %#v, want %#v", definition.ModuleIDs(), tt.want)
}
})
}
}
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
base := DefaultRegistry()
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
DailyToday: {
{ID: module.Metadata},
{ID: module.AlertDigest},
},
})
if err != nil {
t.Fatalf("WithModuleOverrides() error = %v", err)
}
definition, err := overridden.Lookup(DailyToday)
if err != nil {
t.Fatalf("Lookup(overridden) error = %v", err)
}
if !reflect.DeepEqual(definition.ModuleIDs(), []module.ID{module.Metadata, module.AlertDigest}) {
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
}
defaultDefinition, err := base.Lookup(DailyToday)
if err != nil {
t.Fatalf("Lookup(default) error = %v", err)
}
if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) {
t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs())
}
}
func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
_, err := DefaultRegistry().WithModuleOverrides(map[ID][]module.ConfigItem{
ID("unknown"): {{ID: module.Metadata}},
})
if err == nil {
t.Fatal("WithModuleOverrides() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), `unknown report "unknown"`) {
t.Fatalf("error = %q, want unknown report context", err.Error())
}
}
func TestResolvedMetadata(t *testing.T) { func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})

View File

@@ -1,6 +1,10 @@
package report package report
import "fmt" import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type Registry struct { type Registry struct {
definitions map[ID]Definition definitions map[ID]Definition
@@ -17,6 +21,7 @@ func DefaultRegistry() Registry {
BatchOutputName: "daily.md", BatchOutputName: "daily.md",
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTodayModules(),
Morning: true, Morning: true,
resolve: resolveDailyToday, resolve: resolveDailyToday,
}, },
@@ -29,6 +34,7 @@ func DefaultRegistry() Registry {
BatchOutputName: "tomorrow.md", BatchOutputName: "tomorrow.md",
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTomorrowModules(),
Evening: true, Evening: true,
resolve: resolveDailyTomorrow, resolve: resolveDailyTomorrow,
}, },
@@ -41,6 +47,7 @@ func DefaultRegistry() Registry {
BatchOutputName: "three-day.md", BatchOutputName: "three-day.md",
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{ThreeDay}, CompatiblePriorIDs: []ID{ThreeDay},
Modules: threeDayModules(),
Morning: true, Morning: true,
resolve: resolveThreeDay, resolve: resolveThreeDay,
}, },
@@ -53,6 +60,7 @@ func DefaultRegistry() Registry {
BatchOutputName: "weekend.md", BatchOutputName: "weekend.md",
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{Weekend}, CompatiblePriorIDs: []ID{Weekend},
Modules: weekendModules(),
Morning: true, Morning: true,
resolve: resolveWeekend, resolve: resolveWeekend,
}, },
@@ -65,6 +73,7 @@ func DefaultRegistry() Registry {
BatchOutputName: "storm.md", BatchOutputName: "storm.md",
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{Storm}, CompatiblePriorIDs: []ID{Storm},
Modules: stormModules(),
resolve: resolveStorm, resolve: resolveStorm,
}, },
} }
@@ -75,6 +84,99 @@ func DefaultRegistry() Registry {
return registry return registry
} }
func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) {
next := Registry{definitions: map[ID]Definition{}}
for id, definition := range r.definitions {
definition.Modules = append([]module.ConfigItem(nil), definition.Modules...)
next.definitions[id] = definition
}
for id, items := range overrides {
definition, ok := next.definitions[id]
if !ok {
return Registry{}, fmt.Errorf("unknown report %q", id)
}
definition.Modules = cloneModuleItems(items)
next.definitions[id] = definition
}
return next, nil
}
func dailyTodayModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)
}
func dailyTomorrowModules() []module.ConfigItem {
items := dailyTodayModules()
items = append(items, module.ConfigItem{ID: module.TomorrowPlanning})
return items
}
func threeDayModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)
}
func weekendModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.WeekendPlanning,
)
}
func stormModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.HourlyTable,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.StormWindowSummary,
)
}
func moduleItems(ids ...module.ID) []module.ConfigItem {
items := make([]module.ConfigItem, 0, len(ids))
for _, id := range ids {
items = append(items, module.ConfigItem{ID: id})
}
return items
}
func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem {
cloned := make([]module.ConfigItem, len(items))
copy(cloned, items)
return cloned
}
func (r Registry) Lookup(id ID) (Definition, error) { func (r Registry) Lookup(id ID) (Definition, error) {
definition, ok := r.definitions[id] definition, ok := r.definitions[id]
if !ok { if !ok {

View File

@@ -10,9 +10,9 @@ import (
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"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"
) )
@@ -27,7 +27,7 @@ type FilesystemStore struct {
} }
type ArtifactPaths struct { type ArtifactPaths struct {
Briefing string `json:"briefing"` ModuleSnapshot string `json:"moduleSnapshot"`
Metadata string `json:"metadata"` Metadata string `json:"metadata"`
DataPackage string `json:"dataPackage"` DataPackage string `json:"dataPackage"`
Preflight string `json:"preflight"` Preflight string `json:"preflight"`
@@ -44,7 +44,6 @@ type ReportRecord struct {
ValidStart string `json:"validStart"` ValidStart string `json:"validStart"`
ValidEnd string `json:"validEnd"` ValidEnd string `json:"validEnd"`
MetadataPath string `json:"metadataPath"` MetadataPath string `json:"metadataPath"`
BriefingPath string `json:"briefingPath"`
ReportPath string `json:"reportPath,omitempty"` ReportPath string `json:"reportPath,omitempty"`
Warnings int `json:"warnings"` Warnings int `json:"warnings"`
metadata Metadata metadata Metadata
@@ -90,24 +89,27 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
validDate := resolved.ValidPeriod.Start.Format("2006-01-02") validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID filenameBase := metadata.RunID
return ArtifactPaths{ return ArtifactPaths{
Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"), ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"), Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.json"), DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
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"),
}, nil }, nil
} }
func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolved, pkg briefing.Package) (string, error) { func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
paths, err := s.Paths(resolved) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil { if err := snapshot.Validate(); err != nil {
return "", err return "", err
} }
return paths.Briefing, nil if err := fileutil.WriteJSONAtomic(paths.ModuleSnapshot, snapshot); err != nil {
return "", err
}
return paths.ModuleSnapshot, nil
} }
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) { func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
@@ -115,10 +117,7 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
if err != nil { if err != nil {
return "", err return "", err
} }
if err := promptinput.Validate(pkg); err != nil { if err := promptinput.Save(paths.DataPackage, pkg); err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
return "", err return "", err
} }
return paths.DataPackage, nil return paths.DataPackage, nil
@@ -164,8 +163,8 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
if metadata.RunID == "" { if metadata.RunID == "" {
return "", fmt.Errorf("metadata run id is required") return "", fmt.Errorf("metadata run id is required")
} }
if metadata.BriefingPath == "" { if metadata.ModuleSnapshotPath == "" {
return "", fmt.Errorf("metadata briefing path is required") return "", fmt.Errorf("metadata module snapshot path is required")
} }
if metadata.DataPackagePath == "" { if metadata.DataPackagePath == "" {
return "", fmt.Errorf("metadata data package path is required") return "", fmt.Errorf("metadata data package path is required")
@@ -236,7 +235,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
}) })
return &PriorSnapshot{ return &PriorSnapshot{
Metadata: candidates[0], Metadata: candidates[0],
BriefingPath: candidates[0].BriefingPath, ModuleSnapshotPath: candidates[0].ModuleSnapshotPath,
}, nil }, nil
} }
@@ -301,13 +300,31 @@ func (s *FilesystemStore) LoadDataPackage(_ context.Context, path string) (promp
if path == "" { if path == "" {
return promptinput.Package{}, fmt.Errorf("data package path is required") return promptinput.Package{}, fmt.Errorf("data package path is required")
} }
var pkg promptinput.Package data, err := os.ReadFile(path)
if err := readJSON(path, &pkg); err != nil { if err != nil {
return promptinput.Package{}, fmt.Errorf("read %q: %w", path, err)
}
pkg, err := promptinput.LoadYAML(data)
if err != nil {
return promptinput.Package{}, err return promptinput.Package{}, err
} }
return pkg, nil return pkg, nil
} }
func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (module.Snapshot, error) {
if path == "" {
return module.Snapshot{}, fmt.Errorf("module snapshot path is required")
}
var snapshot module.Snapshot
if err := readJSON(path, &snapshot); err != nil {
return module.Snapshot{}, err
}
if err := snapshot.Validate(); err != nil {
return module.Snapshot{}, err
}
return snapshot, nil
}
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 {
@@ -322,7 +339,6 @@ func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano), ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano),
ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano), ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano),
MetadataPath: path, MetadataPath: path,
BriefingPath: metadata.BriefingPath,
ReportPath: metadata.RenderedReportPath, ReportPath: metadata.RenderedReportPath,
Warnings: len(metadata.SourceWarnings), Warnings: len(metadata.SourceWarnings),
metadata: metadata, metadata: metadata,
@@ -402,14 +418,3 @@ func comparablePeriod(metadata Metadata, resolved report.Resolved) bool {
func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool { func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool {
return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start) return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start)
} }
func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) {
if path == "" {
return briefing.Package{}, fmt.Errorf("briefing path is required")
}
var pkg briefing.Package
if err := readJSON(path, &pkg); err != nil {
return briefing.Package{}, err
}
return pkg, nil
}

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"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/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -26,9 +27,9 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.json"), filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"), filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"),
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"), filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.distributor.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"), filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"),
@@ -42,20 +43,35 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
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")
briefingPackage := stateBriefingPackage(resolved) briefingMetadata := stateBriefingMetadata(resolved)
dataPackage, err := promptinput.Build(briefingPackage) snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": resolved.Metadata().RunID}}})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptinput.Metadata{
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
Variant: briefingMetadata.Variant,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Modules: snapshot,
})
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage) dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage)
if err != nil { if err != nil {
t.Fatalf("SaveDataPackage() error = %v", err) t.Fatalf("SaveDataPackage() error = %v", err)
} }
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
if err != nil {
t.Fatalf("SaveModuleSnapshot() error = %v", err)
}
preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`}) preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
if err != nil { if err != nil {
t.Fatalf("SavePreflight() error = %v", err) t.Fatalf("SavePreflight() error = %v", err)
@@ -110,8 +126,8 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
} }
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{ metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
Briefing: briefingPath, ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata, Metadata: paths.Metadata,
DataPackage: dataPackagePath, DataPackage: dataPackagePath,
Preflight: preflightPath, Preflight: preflightPath,
@@ -122,17 +138,24 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
t.Fatalf("SaveMetadata() error = %v", err) t.Fatalf("SaveMetadata() error = %v", err)
} }
for _, path := range []string{briefingPath, dataPackagePath, preflightPath, notificationPath, renderedReportPath, metadataPath} { for _, path := range []string{moduleSnapshotPath, dataPackagePath, preflightPath, notificationPath, renderedReportPath, metadataPath} {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err) t.Fatalf("expected artifact %q: %v", path, err)
} }
} }
loadedBriefing, err := store.LoadBriefing(context.Background(), briefingPath) loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), moduleSnapshotPath)
if err != nil { if err != nil {
t.Fatalf("LoadBriefing() error = %v", err) t.Fatalf("LoadModuleSnapshot() error = %v", err)
} }
if loadedBriefing.Metadata.RunID != resolved.Metadata().RunID { if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 {
t.Fatalf("loaded briefing RunID = %q, want %q", loadedBriefing.Metadata.RunID, resolved.Metadata().RunID) t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot)
}
loadedDataPackage, err := store.LoadDataPackage(context.Background(), dataPackagePath)
if err != nil {
t.Fatalf("LoadDataPackage() error = %v", err)
}
if loadedDataPackage.SchemaVersion != promptinput.SchemaVersion || loadedDataPackage.Briefing.Order[0] != "metadata" {
t.Fatalf("loaded data package = %#v, want YAML package with metadata stanza", loadedDataPackage)
} }
var decoded Metadata var decoded Metadata
data, err := os.ReadFile(metadataPath) data, err := os.ReadFile(metadataPath)
@@ -145,7 +168,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if decoded.RunID != resolved.Metadata().RunID { if decoded.RunID != resolved.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID) t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID)
} }
if decoded.BriefingPath != briefingPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath { if decoded.ModuleSnapshotPath != moduleSnapshotPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded) t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
} }
if decoded.RenderedReportPath != renderedReportPath { if decoded.RenderedReportPath != renderedReportPath {
@@ -162,17 +185,16 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) { func TestSaveMetadataUsesExplicitMetadataPath(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")
briefingPackage := stateBriefingPackage(resolved) briefingMetadata := stateBriefingMetadata(resolved)
paths, err := store.Paths(resolved) paths, err := store.Paths(resolved)
if err != nil { if err != nil {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
} }
otherDir := filepath.Join(t.TempDir(), "other-artifacts") otherDir := filepath.Join(t.TempDir(), "other-artifacts")
otherBriefingPath := filepath.Join(otherDir, resolved.Metadata().RunID+".briefing.json")
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json") derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{ metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
Briefing: otherBriefingPath, ModuleSnapshot: paths.ModuleSnapshot,
Metadata: paths.Metadata, Metadata: paths.Metadata,
DataPackage: paths.DataPackage, DataPackage: paths.DataPackage,
Preflight: paths.Preflight, Preflight: paths.Preflight,
@@ -197,25 +219,7 @@ func TestFindPriorSnapshot(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00") second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
briefingPackage := stateBriefingPackage(first) paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {
@@ -227,8 +231,8 @@ func TestFindPriorSnapshot(t *testing.T) {
if prior.Metadata.RunID != first.Metadata().RunID { if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
} }
if prior.BriefingPath != briefingPath { if prior.ModuleSnapshotPath != paths.ModuleSnapshot {
t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath) t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot)
} }
} }
@@ -236,25 +240,7 @@ func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
briefingPackage := stateBriefingPackage(previousDate) savePriorMetadata(t, store, previousDate, stateBriefingMetadata(previousDate))
briefingPath, err := store.SaveBriefing(context.Background(), previousDate, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(previousDate)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(previousDate, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), currentDate) prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
if err != nil { if err != nil {
@@ -269,25 +255,7 @@ func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00") first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00")
second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00") second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00")
briefingPackage := stateThreeDayBriefingPackage(first) savePriorMetadata(t, store, first, stateBriefingMetadata(first))
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {
@@ -305,25 +273,7 @@ func TestFindPriorSnapshotSupportsWeekend(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00") first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00") second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
briefingPackage := stateWeekendBriefingPackage(first) savePriorMetadata(t, store, first, stateBriefingMetadata(first))
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {
@@ -341,25 +291,7 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00") first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00") second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
briefingPackage := stateWeekendBriefingPackage(first) savePriorMetadata(t, store, first, stateBriefingMetadata(first))
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {
@@ -458,10 +390,8 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
return resolved return resolved
} }
func stateBriefingPackage(resolved report.Resolved) briefing.Package { func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Package{ return briefing.Metadata{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID, RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
Variant: "today", Variant: "today",
@@ -476,51 +406,32 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
Timezone: resolved.Timezone, Timezone: resolved.Timezone,
}, },
ValidPeriod: resolved.ValidPeriod, ValidPeriod: resolved.ValidPeriod,
},
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
} }
} }
func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package { func savePriorMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved, metadata briefing.Metadata) ArtifactPaths {
return briefing.Package{ t.Helper()
Metadata: briefing.Metadata{ paths, err := store.Paths(resolved)
SchemaVersion: briefing.SchemaVersion, if err != nil {
RunID: resolved.Metadata().RunID, t.Fatalf("Paths() error = %v", err)
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
ThreeDay: &briefing.ThreeDay{
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
},
} }
_, err = store.SaveMetadata(context.Background(), BuildMetadataFromBriefingMetadata(resolved, metadata, ArtifactPaths{
ModuleSnapshot: paths.ModuleSnapshot,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
} }
return paths
func stateWeekendBriefingPackage(resolved report.Resolved) briefing.Package {
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Weekend: &briefing.Weekend{
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
},
}
} }
func pathsString(paths ArtifactPaths) string { func pathsString(paths ArtifactPaths) string {
return strings.Join([]string{ return strings.Join([]string{
paths.Briefing,
paths.Metadata, paths.Metadata,
paths.ModuleSnapshot,
paths.DataPackage, paths.DataPackage,
paths.Preflight, paths.Preflight,
paths.Notification, paths.Notification,

View File

@@ -4,9 +4,9 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const MetadataSchemaVersion = "weatherreporter.metadata.v1" const MetadataSchemaVersion = "weatherreporter.metadata.v1"
@@ -25,32 +25,32 @@ type Metadata struct {
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
BriefingPath string `json:"briefingPath"` ModuleSnapshotPath string `json:"moduleSnapshotPath"`
DataPackagePath string `json:"dataPackagePath"` DataPackagePath string `json:"dataPackagePath"`
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"`
} }
func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, paths ArtifactPaths) Metadata { func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
metadata := resolved.Metadata() metadata := resolved.Metadata()
return Metadata{ return Metadata{
SchemaVersion: MetadataSchemaVersion, SchemaVersion: MetadataSchemaVersion,
RunID: metadata.RunID, RunID: metadata.RunID,
MetadataPath: paths.Metadata, MetadataPath: paths.Metadata,
ReportID: metadata.ReportID, ReportID: metadata.ReportID,
Variant: briefingPackage.Metadata.Variant, Variant: briefingMetadata.Variant,
PromptID: metadata.PromptID, PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt, GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone, Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(briefingPackage.Metadata.Location), Location: copyLocation(briefingMetadata.Location),
SourceLocationID: briefingPackage.Metadata.SourceLocationID, SourceLocationID: briefingMetadata.SourceLocationID,
SourceLocation: briefingPackage.Metadata.SourceLocation, SourceLocation: briefingMetadata.SourceLocation,
Sources: briefingPackage.Metadata.Sources, Sources: briefingMetadata.Sources,
SourceWarnings: briefingPackage.Metadata.SourceWarnings, SourceWarnings: briefingMetadata.SourceWarnings,
BriefingPath: paths.Briefing, ModuleSnapshotPath: paths.ModuleSnapshot,
DataPackagePath: paths.DataPackage, DataPackagePath: paths.DataPackage,
PreflightPath: paths.Preflight, PreflightPath: paths.Preflight,
RenderedReportPath: paths.RenderedReport, RenderedReportPath: paths.RenderedReport,

View File

@@ -6,26 +6,26 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "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"
) )
type Store interface { type Store interface {
Paths(report.Resolved) (ArtifactPaths, error) Paths(report.Resolved) (ArtifactPaths, error)
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error) SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error)
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)
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)
LoadBriefing(context.Context, string) (briefing.Package, error) LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
} }
type PriorSnapshot struct { type PriorSnapshot struct {
Metadata Metadata Metadata Metadata
BriefingPath string ModuleSnapshotPath string
} }
type PreflightArtifact struct { type PreflightArtifact struct {

View File

@@ -1,5 +1,5 @@
// Package forecast defines normalized weather data consumed by report builders. // Package weatherdata defines normalized weather data collected from sources.
package forecast package weatherdata
import ( import (
"encoding/json" "encoding/json"
@@ -33,12 +33,12 @@ type Source struct {
} }
type SourceWarning struct { type SourceWarning struct {
Source string `json:"source"` Source string `json:"source" yaml:"source"`
Code string `json:"code"` Code string `json:"code" yaml:"code"`
Severity string `json:"severity"` Severity string `json:"severity" yaml:"severity"`
Message string `json:"message"` Message string `json:"message" yaml:"message"`
Endpoint string `json:"endpoint,omitempty"` Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
CompletenessImpact string `json:"completenessImpact,omitempty"` CompletenessImpact string `json:"completenessImpact,omitempty" yaml:"completeness_impact,omitempty"`
} }
type Observation struct { type Observation struct {