8 Commits

55 changed files with 2373 additions and 2551 deletions

View File

@@ -129,8 +129,8 @@ name the variable only; they should not contain the token value.
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
- `sources`: optional map of source-specific overrides, using the same policy values.
Hourly forecast data is required for generated reports. Optional sources and
stub source slots use the missing-source policy.
Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy.
### `scriptorium`
@@ -196,10 +196,12 @@ reports:
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- id: area_forecast_discussion
options:
sections:
- short_term
- hourly_forecast
```
Unknown reports, unknown modules, duplicate modules, incompatible report/module
@@ -209,8 +211,8 @@ combinations, duplicate stanza names, and invalid options fail config loading.
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.
[Module Contract Internals](internal/module.md). Unknown or unimplemented
module IDs fail validation instead of being skipped.
## Secrets

View File

@@ -89,10 +89,6 @@ source-specific `missing_source.sources` policy:
- `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest`
The adapter also creates a missing stub source record for `daily` because that
source slot exists in the internal bundle but is not fetched from the Weather
API.
Policy behavior:
- `error`: fail the fetch for that source

View File

@@ -27,15 +27,18 @@ Outputs:
- `ModuleDefinition` values with module ID, stanza name, option type,
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `alert_digest`,
`area_forecast_discussion`, and `weather_story`
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `area_forecast_discussion`, and `weather_story`
- `module.Output` values for derived stanzas:
`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.
Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped.
Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts.
## Boundaries

View File

@@ -31,23 +31,19 @@ The registry recognizes these IDs:
- `metadata`
- `current_conditions`
- `narrative_forecast`
- `hourly_forecast`
- `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.
Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped.
## Options

View File

@@ -24,7 +24,8 @@ Inputs:
Outputs:
- `promptinput.Package` with schema version, RunID, report metadata, named
module stanzas, Recent Changes, and source warnings
module stanzas grouped for prompt presentation, Recent Changes, and source
warnings
- YAML bytes from `promptinput.MarshalYAML`
- YAML file written atomically by `promptinput.Save`
@@ -38,13 +39,37 @@ report:
prompt_id: <prompt_id>
briefing:
metadata: {}
applicable_risk_products:
alert_digest: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
precip_timing: {}
outdoor_windows: {}
narrative_products:
narrative_forecast: {}
area_forecast_discussion: {}
weather_story: {}
raw_data:
current_conditions: {}
hourly_forecast: {}
recent_changes:
items: []
```
The `briefing` mapping contains named module stanzas. Stanza order follows the
module snapshot output order.
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
discussions, and similar risk products.
- `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories.
- `raw_data`: minimally transformed underlying weather data.
## Boundaries
@@ -90,6 +115,7 @@ Inspect:
## Invariants
- Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic for generated snapshots.
- Module stanza order is deterministic within each prompt-facing category.
- Every non-metadata module stanza has exactly one prompt-input category.
- Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text.

View File

@@ -22,7 +22,6 @@ Outputs:
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings
- stub source record for the daily forecast source slot
- optional saved bundle JSON through app fetch helpers
## Boundaries
@@ -70,7 +69,7 @@ data is required and cannot be skipped.
- HTTP errors, response read failures, and envelope decode failures include
endpoint context.
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
- Optional and stub sources follow missing-source policy.
- Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.

View File

@@ -1,34 +0,0 @@
# Distributor Roadmap
Current distributor notification behavior is documented outside the roadmap:
- [Configuration reference](../config.md)
- [Operations guide](../operations.md)
- [Troubleshooting](../troubleshooting.md)
- [Distributor adapter internals](../internal/distributor-adapter.md)
This file tracks future distributor-related work only.
## Deferred Enhancements
- Add a supported warning-only notification policy.
- Include selected non-report artifacts in uploaded bundles.
- Poll distributor run status after upload acceptance.
- Persist upload retry state across process restarts.
- Add explicit CLI controls for distributor behavior.
## Non-Goals Without A Separate Design
- Do not make distributor scan the weatherreporter workspace.
- Do not move destination routing into weatherreporter.
- Do not move Markdown-to-HTML transformation into weatherreporter.
- Do not store raw bearer tokens in configuration files.
## Required Constraints For Future Work
- Distributor package types stay inside `internal/adapters/distributor`.
- Weatherreporter submits explicit source bundles built from generated files.
- Optional `--out` and `--out-dir` copies remain operator conveniences, not
canonical upload sources.
- Secret values stay out of errors, logs, CLI output, metadata, examples, and
documentation.

View File

@@ -3,7 +3,7 @@
This roadmap contains project work that is not implemented. Current behavior is
documented outside `docs/roadmap/`.
## Deferred: Automatic Storm Monitoring
## Automatic Storm Monitoring
Manual Storm Report generation is implemented through
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
@@ -34,30 +34,56 @@ Acceptance criteria before implementation:
- evaluator failures are inspectable and do not create noisy report output;
- manual Storm Report generation remains available.
## Deferred: Alternate Runtime Integrations
## Future Report Types And Modules
These ideas are not current behavior:
The module-based prompt package architecture is implemented. Future work should
add only modules backed by implemented upstream facts and clear report needs.
- native LLM client inside `weatherreporter`;
- database-backed state;
- public HTTP API;
- multi-location selection;
- daemon mode;
- multi-user authorization;
- plugin system.
Possible future report types:
Each item needs its own design note before implementation. Non-roadmap docs
must not describe these as available behavior.
- `next_6_hours` or another short-fuse planning report;
- event-specific reports with stable event IDs;
- storm review or yesterday-style reports using historical observations;
- archive-focused report variants if generated report history becomes a
first-class product.
## Deferred: Distributor Notification Enhancements
Possible future modules:
- `hourly_table` for compact valid-period hourly facts;
- `forecast_delta` if a separate stanza is useful beyond current Recent
Changes;
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza;
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
prompt-facing storm-window module;
- separate AFD section aliases, such as `afd_key_messages`,
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
more useful than `area_forecast_discussion.options.sections`;
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
upstream sources and report requirements exist.
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
remain omitted until a real upstream quantitative precipitation source is
represented in `CollectedFacts`.
Future module work should preserve these boundaries:
- collect upstream facts once per report run;
- keep upstream fetching out of modules;
- keep broad reusable calculations in `DerivedFacts`;
- keep prompt-facing field shape inside module builders;
- use typed options for configurable module behavior;
- keep module snapshots structured and deterministic for Recent Changes.
## Distributor Notification Enhancements
Distributor notification currently uploads one managed Markdown report per
successful generated report through the configured HTTP upload endpoint.
successful generated report through the configured HTTP upload pipeline.
These enhancements are not current behavior:
- `failure_policy: warn`;
- uploading metadata, briefing snapshots, data packages, or preflight artifacts;
- uploading metadata, module snapshots, data packages, or preflight artifacts;
- polling distributor status after upload acceptance;
- durable upload retry queues;
- distributor-specific CLI flags;
@@ -69,7 +95,26 @@ Any distributor enhancement should preserve the existing adapter boundary:
weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior.
## Deferred: Cleanup Refactors
## Alternate Runtime Integrations
These ideas are not current behavior:
- native LLM client inside `weatherreporter`;
- database-backed state;
- public HTTP API;
- multi-location selection;
- daemon mode;
- multi-user authorization;
- plugin system;
- dynamic module loading;
- user-defined module code;
- YAML-defined module schemas;
- module-owned Weather API fetching.
Each item needs its own design note before implementation. Non-roadmap docs
must not describe these as available behavior.
## Cleanup Refactors
The initial cleanup pass intentionally left these refactors out because the
current implementation does not yet make them worth the added abstraction.
@@ -81,14 +126,12 @@ more expensive:
- Weather API optional-source specification/helper refactor: consider when
additional Weather API sources make per-source fan-out, policy handling, and
provenance wiring repetitive enough to obscure adapter behavior.
- Broad briefing weather-signal consolidation: consider when multiple briefing
- Broad briefing weather-signal consolidation: consider when multiple module
builders repeatedly derive the same weather signals and tests begin to need
coordinated fixture updates.
- Generic workflow engine: defer unless generation, inspection, recovery, or
future background workflows gain enough shared step semantics to justify a
declared execution model.
- Plugin architecture: defer until there is a concrete external extension
contract and at least one implemented extension point.
- Cobra migration: defer while the standard-library CLI remains small,
explicit, and covered by parser tests.
- Manifest, resume, or progress system: defer until operators need resumable
@@ -101,5 +144,5 @@ more expensive:
artifact output.
Any future implementation should preserve the existing public CLI, artifact
paths, report identities, and adapter boundaries unless a separate roadmap
explicitly changes them.
paths, report identities, module boundaries, and adapter boundaries unless a
separate roadmap explicitly changes them.

View File

@@ -1,791 +0,0 @@
# Modular Data Package Implementation Roadmap
This roadmap is a staged implementation plan for
[`docs/roadmap/modules.md`](modules.md). It is future-work planning only. The
target audience is an LLM coding agent implementing each stage in order.
## Purpose
Implement a pre-release hard cutover from report-shaped briefing packages to a
module-oriented prompt package architecture:
```text
CollectedFacts -> DerivedFacts -> ModuleOutput
```
The implementation should produce YAML `data_package` artifacts with named
stanzas for Scriptorium prompts, persist JSON module snapshots for inspection
and Recent Changes, and make report composition configurable through ordered
module IDs plus typed module options.
## Source Roadmap
[`docs/roadmap/modules.md`](modules.md) is authoritative for the conceptual
policy, user intent, target prompt shape, boundaries, and acceptance criteria
for this refactor. This document is authoritative for implementation order,
stage scope, file/package guidance, and validation commands.
If this implementation plan appears to conflict with `modules.md`, stop and
reconcile the roadmap before changing code. Do not infer a different policy
from stage sequencing.
## Locked Decisions
- Do a clean break. Do not preserve old report-shaped briefing JSON as a
compatibility layer.
- Introduce `internal/weatherdata` for normalized collected source types.
- Keep `internal/forecast` for forecast-specific derivation algorithms such as
period selection, daily summaries, daypart grouping, and precipitation
timing.
- Introduce an internal fact contract for `CollectedFacts` and `DerivedFacts`.
- Introduce a narrow module contract for module IDs, typed options, outputs,
and snapshots.
- Persist both artifacts:
- JSON module snapshots for state, inspection, and Recent Changes;
- YAML prompt data packages passed to Scriptorium as `data_package`.
- Replace `weatherreporter inspect briefing` with
`weatherreporter inspect modules`.
- Support typed module options from the first configurable composition pass.
- Use named YAML stanzas, not a generic array of module objects.
- Omit QPF fields until a real upstream QPF source is represented in
`CollectedFacts`.
- Keep public generate/run command names, report IDs, prompt IDs, RunID format,
managed Markdown report paths, and distributor upload source stable.
- Do not introduce plugins, dynamic loading, generic workflow engines, or
module-owned upstream fetching.
## Target Packages
Implementation should converge on this package ownership:
- `internal/weatherdata`: normalized collected source facts, source metadata,
source warnings, and broad weather data types.
- `internal/forecast`: deterministic forecast-specific algorithms over
`weatherdata` types.
- `internal/facts`: `CollectedFacts`, `DerivedFacts`, and their builders.
- `internal/module`: stable module IDs, module output envelope, module snapshot
shape, module config item shape, and shared option/output contracts that must
be imported by both `internal/report` and `internal/briefing`.
- `internal/briefing`: module registry and module builders.
- `internal/report`: report definitions, valid periods, output identity,
comparison strategy, and default module composition.
- `internal/config`: YAML config structs, defaults, loading, and validation for
module composition overrides.
- `internal/promptinput`: YAML prompt package assembly, validation, and save
behavior.
- `internal/changes`: structured comparison over module snapshots.
- `internal/state`: module snapshot paths, YAML data package paths, metadata
links, and inspection loads.
- `internal/app`: orchestration only.
Avoid import cycles. In particular, `internal/report` may import
`internal/module` for module IDs, but `internal/module` must not import
`internal/report`.
## Target Artifacts
Use explicit schema versions:
- Module snapshot JSON: `weatherreporter.modules.v1`
- YAML prompt data package: `weatherreporter.data_package.v2`
Target workspace paths:
```text
workspace/
snapshots/<artifact_group>/<valid_date>/<run_id>.modules.json
snapshots/<artifact_group>/<valid_date>/<run_id>.metadata.json
data-packages/<artifact_group>/<valid_date>/<run_id>.data_package.yaml
```
Metadata should link the module snapshot and YAML data package paths. Existing
metadata links for preflight, rendered report, source warnings, source hashes,
and distributor notification artifacts should remain.
## Target Module Defaults
Initial implemented default module IDs should cover current behavior without
QPF-specific fields:
- `metadata`
- `current_conditions`
- `derived_daily_summary`
- `derived_daypart_summaries`
- `precip_timing`
- `alert_digest`
- `area_forecast_discussion`
- `weather_story`
- `forecast_delta`
- `outdoor_windows`
- `weekend_planning`
- `storm_window_summary`
Default report composition should be declared in `internal/report`:
- Daily Today:
`metadata`, `current_conditions`, `derived_daily_summary`,
`derived_daypart_summaries`, `precip_timing`, `alert_digest`,
`forecast_delta`, `area_forecast_discussion`, `weather_story`,
`outdoor_windows`
- Daily Tomorrow:
same as Daily Today, plus any tomorrow-planning module needed to preserve
current tomorrow behavior.
- 3-Day:
`metadata`, `current_conditions`, `derived_daypart_summaries`,
`precip_timing`, `alert_digest`, `forecast_delta`,
`area_forecast_discussion`, `weather_story`, `outdoor_windows`
- Weekend:
`metadata`, `current_conditions`, `derived_daypart_summaries`,
`precip_timing`, `alert_digest`, `area_forecast_discussion`,
`weather_story`, `outdoor_windows`, `weekend_planning`
- Storm:
`metadata`, `current_conditions`, `hourly_table`, `precip_timing`,
`alert_digest`, `area_forecast_discussion`, `weather_story`,
`storm_window_summary`
If preserving a current report behavior requires a narrower module, add a
specific module rather than keeping old report-shaped containers.
## Stage 1: Weatherdata Package Split
Goal: separate broad normalized weather data from forecast-specific derivation.
Files/packages to change:
- create `internal/weatherdata`;
- update `internal/forecast`;
- update `internal/adapters/weatherapi`;
- update packages that currently import normalized source types from
`internal/forecast`.
Implementation guidance:
- Move normalized source/domain types out of `internal/forecast` when they are
not forecast algorithms:
- bundle/source metadata/warnings;
- current conditions;
- observation run types if present;
- alert run and alert overlap source types;
- forecast run and forecast period source types;
- discussion and discussion section types;
- weather story types.
- Keep deterministic derivation functions in `internal/forecast`.
- Update Weather API adapter return types to use `weatherdata.Bundle`.
- Keep JSON field names and Weather API fixture behavior unchanged.
- Do not change CLI behavior, artifact paths, or prompt input yet.
Acceptance criteria:
- Weather API adapter tests pass with `weatherdata` types.
- Forecast derivation tests pass using `weatherdata` inputs.
- No external adapter dependency types leak into `weatherdata`.
- Existing generated report behavior is unchanged at this stage.
Validation:
```bash
go test ./internal/weatherdata ./internal/forecast ./internal/adapters/weatherapi
go test ./internal/app ./internal/briefing ./internal/promptinput
```
This stage is suitable for one implementation prompt if kept mechanical.
## Stage 2: Fact Contracts
Goal: add explicit `CollectedFacts` and `DerivedFacts` contracts.
Files/packages to change:
- create `internal/facts`;
- update `internal/app`;
- update `internal/forecast` tests as needed.
Implementation guidance:
- Define `CollectedFacts` as normalized upstream facts collected once per
report run.
- Define `DerivedFacts` as conservative, reusable, report-scoped
transformations.
- Add builders:
- `BuildCollected(bundle *weatherdata.Bundle) CollectedFacts`
- `BuildDerived(req BuildDerivedRequest) (DerivedFacts, error)`
- `BuildDerivedRequest` should include the resolved report, timezone,
configured dayparts, and `CollectedFacts`.
- `DerivedFacts` may include:
- valid-period hourly periods;
- valid-period narrative periods;
- alert overlaps;
- daily summaries;
- daypart summaries where reusable;
- precipitation timing if reused by multiple modules.
- Do not put prompt wording, prose strings, module-specific ranking, or
one-off presentation decisions in `DerivedFacts`.
- Keep source provenance and warnings separate from ordinary fact access.
Acceptance criteria:
- `CollectedFacts` can be built once from a fetched bundle.
- `DerivedFacts` can be built for Daily, Tomorrow, 3-Day, Weekend, and Storm.
- Derived fact builders have tests for valid-period slicing, daypart grouping,
alert overlaps, and missing optional sources.
- No module or prompt code exists yet that fetches upstream data.
Validation:
```bash
go test ./internal/facts ./internal/forecast ./internal/app
go test ./internal/...
```
This stage is suitable for one implementation prompt.
## Stage 3: Module Core Contracts
Goal: define module IDs, options, outputs, snapshots, and registry mechanics.
Files/packages to change:
- create `internal/module`;
- update `internal/report`;
- update `internal/briefing`.
Implementation guidance:
- Define:
- `module.ID`;
- module ID constants;
- `module.ConfigItem`;
- `module.Output`;
- `module.Snapshot`;
- shared schema version constants.
- `module.Output` should contain module ID, stanza name, and typed value.
- `module.Snapshot` should preserve ordered outputs and support typed stanza
lookup for comparison code.
- Add duplicate module and duplicate stanza-name validation.
- Add typed option structs for initial modules. Empty option structs are fine
for modules without options.
- Add a module registry in `internal/briefing` that maps module IDs to builder
definitions.
- Module definitions should declare:
- ID;
- stanza name;
- option type;
- default options;
- required collected facts;
- required derived facts;
- supported report IDs or report categories;
- missing-data behavior.
- Do not execute modules from app orchestration yet unless needed for tests.
Acceptance criteria:
- Report definitions can refer to `module.ID` without import cycles.
- Module registry tests reject unknown modules, duplicate module IDs, duplicate
stanza names, incompatible reports, and invalid option shapes.
- Module output and snapshot JSON marshal deterministically enough for tests.
Validation:
```bash
go test ./internal/module ./internal/briefing ./internal/report
```
This stage is suitable for one implementation prompt.
## Stage 4: Base Modules
Goal: implement source-oriented modules that mostly pass through normalized or
lightly selected facts.
Files/packages to change:
- `internal/briefing`;
- `internal/module`;
- tests under `internal/briefing`.
Implementation guidance:
- Implement these modules:
- `metadata`;
- `current_conditions`;
- `alert_digest`;
- `area_forecast_discussion`;
- `weather_story`.
- The `metadata` module should expose report metadata, configured location,
units, timezone, valid period, source warnings summary, and alert checked
status where appropriate.
- `area_forecast_discussion` should expose key messages, short-term text, and
long-term text when present.
- `weather_story` should expose structured story fields when present and omit
the stanza when missing/suppressed by missing-source policy.
- `alert_digest` should distinguish checked/no-active-alerts from missing alert
source data.
- Ordinary modules should not expose endpoint, hash, or transport provenance;
provenance should remain metadata/source-warning oriented.
Acceptance criteria:
- Each module has focused tests for available data, missing optional data, and
empty output omission.
- No module fetches upstream data or reads/writes durable state.
- Output field names use YAML-friendly snake_case and unit suffixes where
needed.
Validation:
```bash
go test ./internal/briefing ./internal/module ./internal/facts
```
This stage is suitable for one implementation prompt.
## Stage 5: Derived Fact Modules
Goal: implement deterministic modules that package reusable forecast
derivations for the LLM.
Files/packages to change:
- `internal/forecast`;
- `internal/facts`;
- `internal/briefing`;
- `internal/module`.
Implementation guidance:
- Implement:
- `derived_daily_summary`;
- `derived_daypart_summaries`;
- `precip_timing`;
- `outdoor_windows`;
- any tomorrow-planning module needed to preserve Tomorrow output quality.
- `derived_daily_summary` should include current implementable fields:
- `high_temp_f`;
- `low_temp_f`;
- `max_pop_percent`;
- `max_pop_window`;
- `first_precip_hour`;
- `last_precip_hour`;
- `thunder_mentioned`;
- `max_wind_gust_mph`;
- `heat_index_max_f` when source data supports it.
- Do not implement `measurable_qpf_total_in` or `max_hourly_qpf_in` until QPF
exists in `CollectedFacts`.
- `derived_daypart_summaries` should expose daypart keyed values using the
configured daypart definitions.
- Keep broad reusable calculations in `DerivedFacts`; keep prompt-shape
packaging inside modules.
Acceptance criteria:
- Derived modules have fixture coverage across ordinary, dry, rainy, windy,
cold/heat, and missing-data scenarios.
- QPF fields are absent unless an upstream QPF source exists.
- Daily and Tomorrow module outputs contain enough data to replace current
report-shaped daily briefing content.
Validation:
```bash
go test ./internal/forecast ./internal/facts ./internal/briefing ./internal/module
```
This stage may be too large for one prompt if all modules are implemented at
once. Split into Daily-derived modules first, then outlook/storm derived
modules if needed.
## Stage 6: Report Composition And Config Overrides
Goal: make report definitions and config the source of module composition.
Files/packages to change:
- `internal/report`;
- `internal/config`;
- `examples/config.yml`;
- config tests.
Implementation guidance:
- Extend `report.Definition` with default ordered module IDs.
- Keep valid-period resolution, prompt IDs, output naming, generated flag, and
comparison strategy in `internal/report`.
- Add config support:
```yaml
reports:
daily:
deterministic_modules:
- current_conditions
- id: area_forecast_discussion
options:
sections:
- short_term
```
- Support both string shorthand and object form for module entries.
- Normalize config into typed `module.ConfigItem` values.
- Decode module options into typed option structs during validation or before
module execution.
- Reject:
- unknown report IDs;
- unknown module IDs;
- duplicate modules unless explicitly allowed by that module;
- duplicate stanza names;
- incompatible report/module combinations;
- invalid options.
- Built-in defaults should work when no report module config is present.
- Example config may omit module overrides unless an example is needed.
Acceptance criteria:
- Defaults reproduce intended module composition for all implemented reports.
- A config edit can add/remove an implemented module for a report.
- Invalid module config errors are actionable and do not mention raw internal
panic/details.
- Config examples load.
Validation:
```bash
go test ./internal/report ./internal/config ./internal/briefing
go run ./cmd/weatherreporter --help
```
This stage is suitable for one implementation prompt.
## Stage 7: Module Snapshot State
Goal: persist and inspect JSON module snapshots without changing Scriptorium
input yet.
Files/packages to change:
- `internal/state`;
- `internal/app`;
- `internal/cli`;
- app/state/CLI tests.
Implementation guidance:
- Add state paths for `<run_id>.modules.json`.
- Add save/load methods for module snapshots.
- Update metadata to include `ModuleSnapshotPath`.
- Add `weatherreporter inspect modules [--config PATH] RUN_ID`.
- Remove `inspect briefing` from parser support and help text in this stage.
- Keep old data package generation in place only until Stage 8, but do not
leave generation without a module snapshot.
Acceptance criteria:
- Generated runs persist module snapshots before prompt package construction.
- `inspect modules` returns the module snapshot.
- `inspect briefing` is gone from help text and parser tests.
- Metadata links the module snapshot path.
- Existing report generation still succeeds with fake Scriptorium.
Validation:
```bash
go test ./internal/state ./internal/cli ./internal/app
go run ./cmd/weatherreporter --help
```
This stage is suitable for one implementation prompt.
## Stage 8: YAML Prompt Package Cutover
Goal: replace JSON prompt data packages with YAML named-stanza data packages.
Files/packages to change:
- `internal/promptinput`;
- `internal/state`;
- `internal/adapters/scriptorium` tests;
- `internal/app`.
Implementation guidance:
- Set prompt package schema version to `weatherreporter.data_package.v2`.
- Build prompt package content from module snapshots, report metadata, recent
changes, and source warnings.
- Save prompt packages as `.data_package.yaml`.
- Continue passing Scriptorium input as `--input data_package=<path>`.
- Update render/run tests to avoid assuming `.json` filenames.
- Ensure YAML uses named stanzas under `briefing`.
- Omit empty optional fields.
- Keep module snapshot JSON as the comparison/inspection source.
- Update metadata `DataPackagePath` to point to YAML.
Acceptance criteria:
- Scriptorium render and run receive a YAML `data_package` path.
- YAML output is deterministic enough for tests.
- `inspect data-package` returns YAML content or a parsed representation
chosen consistently in CLI tests.
- No code assumes data package paths end in `.json`.
Validation:
```bash
go test ./internal/promptinput ./internal/adapters/scriptorium ./internal/state ./internal/app ./internal/cli
go test ./...
```
This stage is suitable for one implementation prompt.
## Stage 9: App Orchestration Cutover
Goal: make module execution the only generation path for all implemented
reports.
Files/packages to change:
- `internal/app`;
- `internal/briefing`;
- `internal/facts`;
- app workflow tests.
Implementation guidance:
- In `GenerateReport`, fetch Weather API data once, build `CollectedFacts`,
build `DerivedFacts`, execute configured modules, save module snapshot, build
YAML prompt package, then continue preflight/run/metadata/distributor flow.
- Preserve ordering:
1. resolve prior comparable metadata;
2. fetch bundle;
3. build facts;
4. execute modules;
5. save module snapshot;
6. compute Recent Changes;
7. save YAML data package;
8. run render preflight;
9. save metadata;
10. run Scriptorium;
11. copy optional output;
12. save final metadata;
13. notify distributor if enabled.
- Do not use `--out` or `--out-dir` copies for distributor notification.
- Do not invoke modules after Scriptorium failures.
- Keep batch behavior unchanged: continue independent reports, return nonzero
aggregate status if any report fails.
Acceptance criteria:
- Daily, Tomorrow, 3-Day, Weekend, and Storm generation all use module
snapshots and YAML data packages.
- Existing public CLI syntax remains stable except `inspect modules` replacing
`inspect briefing`.
- Managed Markdown report paths and distributor upload source remain stable.
- App tests assert generated module snapshots and YAML data packages.
Validation:
```bash
go test ./internal/app ./internal/cli ./internal/state ./internal/briefing ./internal/promptinput
go test ./...
```
This stage may be large. Split by report family if needed: Daily/Tomorrow,
Outlooks, then Storm.
## Stage 10: Recent Changes Migration
Goal: compare structured module snapshots instead of report-shaped briefing
packages.
Files/packages to change:
- `internal/changes`;
- `internal/state`;
- `internal/app`;
- changes tests.
Implementation guidance:
- Define which module stanzas each comparison strategy consumes.
- Daily comparison should use `derived_daily_summary`,
`derived_daypart_summaries`, `alert_digest`, and `precip_timing` where
present.
- 3-Day and Weekend comparisons should use module snapshot outputs that replace
current outlook day comparisons.
- Storm comparison should remain explicit-window based and consume storm
module outputs when implemented.
- Do not compare rendered Markdown or rendered YAML text.
- If a comparison-required module is missing, return an actionable error or an
inspectable warning according to the report policy chosen in code. Prefer an
error for required comparison modules and no-op only for optional comparison
stanzas.
Acceptance criteria:
- Prior snapshot lookup still uses report compatibility and valid-period rules.
- Recent Changes output remains deterministic.
- Tests cover unchanged forecasts, threshold-crossing changes, alert changes,
precip timing changes, and missing comparison stanzas.
- Old `briefing.Package` comparison code is removed.
Validation:
```bash
go test ./internal/changes ./internal/state ./internal/app
go test ./...
```
This stage is suitable for one implementation prompt if module snapshots are
already available.
## Stage 11: Remove Old Briefing Shapes
Goal: remove obsolete report-shaped briefing containers and stale JSON package
assumptions.
Files/packages to change:
- `internal/briefing`;
- `internal/promptinput`;
- `internal/state`;
- `internal/app`;
- tests throughout `internal`.
Implementation guidance:
- Remove old `Daily`, `ThreeDay`, `Weekend`, and `Storm` briefing container
structs when no longer used.
- Remove old `briefing.Package` if it no longer represents the module
snapshot. If the package keeps a `Package` type, it must be module-oriented.
- Remove tests that construct old report-shaped briefing fixtures.
- Remove stale `.data_package.json` assumptions.
- Remove dead helper functions that only supported old report-shaped output.
- Keep generated report Markdown behavior stable.
Acceptance criteria:
- `rg -n "data_package\\.json|inspect briefing|briefing\\.Package" internal docs -g '!docs/roadmap/**'`
has no production-code matches, except deliberate roadmap/history references
where appropriate.
- No old report-shaped content structs remain on the generation path.
- All tests pass.
Validation:
```bash
rg -n "data_package\\.json|inspect briefing|briefing\\.Package" internal docs -g '!docs/roadmap/**'
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
This stage is suitable for one implementation prompt.
## Stage 12: Documentation And Example Alignment
Goal: align non-roadmap docs with implemented module behavior.
Files to inspect/update:
- `README.md`, only if the orientation or quickstart changed;
- `docs/cli.md`;
- `docs/config.md`;
- `docs/operations.md`;
- `docs/troubleshooting.md`;
- `docs/internal/app-orchestration.md`;
- `docs/internal/briefing.md` or replacement module internals doc;
- `docs/internal/changes.md`;
- `docs/internal/forecast-derivation.md`;
- `docs/internal/prompt-input.md`;
- `docs/internal/state.md`;
- `docs/internal/weather-data.md`;
- `docs/integrations/scriptorium.md`;
- `examples/config.yml`.
Implementation guidance:
- Document only implemented behavior outside `docs/roadmap/`.
- Add or update an internal module contract document if module behavior is now
implemented.
- Document `inspect modules` and remove `inspect briefing`.
- Document YAML data packages and JSON module snapshots.
- Document report module overrides and typed options only if implemented.
- Keep QPF as future-only unless upstream support was added.
- Keep Scriptorium contract focused on `--input data_package=<path>` and the
actual file format now passed.
Acceptance criteria:
- Non-roadmap docs no longer describe old report-shaped briefing packages.
- Config examples load.
- CLI examples match `weatherreporter --help`.
- Docs clearly distinguish module snapshots from prompt data packages.
Validation:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
rg -n "inspect briefing|data_package\\.json|report-shaped|vars-file|promptvars" README.md docs examples internal -g '!docs/roadmap/**'
```
This stage is suitable for one implementation prompt.
## Stage 13: Final Validation
Goal: run full validation and catch stale assumptions after the cutover.
Required commands:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Required grep checks:
```bash
rg -n "inspect briefing|data_package\\.json|briefing\\.Package|Daily struct|ThreeDay struct|Weekend struct|Storm struct" internal docs examples -g '!docs/roadmap/**'
rg -n "measurable_qpf_total_in|max_hourly_qpf_in" internal docs examples -g '!docs/roadmap/**'
```
Expected grep results:
- no production-code references to `inspect briefing`;
- no production-code assumption that prompt packages are JSON;
- no production-code dependence on old report-shaped briefing containers;
- QPF references appear only as future-target docs or omitted-field tests until
upstream QPF exists.
Manual review:
- Generate command output still writes managed Markdown reports.
- Batch behavior still continues independent reports and returns nonzero on
aggregate failure.
- Distributor notification still uploads the managed Markdown report, not
module snapshots or YAML prompt packages.
- Secrets are not printed or persisted.
- YAML prompt package is readable and contains named stanzas.
## Deferred Work
Do not include these in the initial module cutover:
- plugin architecture;
- dynamic module loading;
- YAML-defined module schemas;
- user-authored module code;
- module-owned Weather API fetching;
- QPF fields before upstream QPF exists;
- SPC modules before upstream SPC data exists;
- radar modules before upstream radar data exists;
- event/storm-review reports unless a separate roadmap implements them.
## Open Questions
No blocking open questions remain for this implementation plan. The previously
identified choices are locked above:
- persist JSON module snapshots and YAML prompt data packages;
- split broad normalized source types into `internal/weatherdata`;
- replace `inspect briefing` with `inspect modules`;
- support typed module options from the first config implementation.

View File

@@ -1,677 +0,0 @@
# Modular Data Package Roadmap
This roadmap describes planned refactoring work that is not implemented.
Current behavior is documented outside `docs/roadmap/`.
## Purpose
Move weatherreporter toward deterministic, reusable briefing modules that can
be composed per report type. The goal is to make prompt input easier for the
LLM to understand, easier for operators to inspect, and easier for developers
to change without touching a cross-cutting set of report-builder files.
The target outcome is a prompt-facing YAML data package with named stanzas. Each
stanza should be built by a self-contained module that derives clear,
deterministic facts from normalized forecast inputs. Reports should choose
modules by ordered module IDs, so experimenting with a report can be as small
as changing one configuration line, plus any matching prompt change outside
weatherreporter.
The internal target shape is:
```text
CollectedFacts -> DerivedFacts -> ModuleOutput
```
Each arrow should be a stable internal contract. `DerivedFacts` should not need
to know how `CollectedFacts` were collected. Modules should not need to know
the provenance of any collected or derived fact they consume. Report building
should not need to know how a module sourced its underlying facts or calculated
its output.
## Intent And Context
The current application already curates source data before passing it to the
LLM. This refactor should strengthen that design. Modules should not expose raw
source complexity merely because it is available. They should compute and
package the facts the LLM should not have to infer from raw hourly periods,
alerts, narrative periods, forecast discussions, or weather stories.
The desired module behavior is deterministic. A module should answer a narrow
question such as:
- what are the current conditions;
- what are the key daily forecast facts;
- what are the daypart summaries;
- when is precipitation most likely;
- which alerts overlap the report period;
- what short-term AFD text is relevant;
- what weather story text is relevant.
The primary maintainability goal is local reasoning. For example, updating the
derived daily summary should mostly involve one module implementation and its
tests. Adding AFD short-term text to a future `next_six_hours` report should be
a report composition change, not a copy/paste change across multiple builders.
## Target Prompt Shape
The target prompt-facing data package should be YAML with named stanzas under
`briefing`. Named stanzas are preferred over an array of generic module objects
because they are easier to read, inspect, and reference in prompts.
Example target shape:
```yaml
report:
id: daily_today
prompt_id: weather.daily_report
generated_at: 2026-06-09T07:15:00-05:00
timezone: America/Chicago
current_local_date: 2026-06-09
valid_period:
start: 2026-06-09T00:00:00-05:00
end: 2026-06-10T00:00:00-05:00
briefing:
metadata:
location:
id: home
name: Brentwood
region: St. Louis Metro
timezone: America/Chicago
current_conditions:
condition_text: Partly cloudy
temperature_f: 74
apparent_temperature_f: 76
dewpoint_f: 66
relative_humidity_percent: 71
wind_speed_mph: 8
wind_direction_degrees: 190
derived_daily_summary:
high_temp_f: 86
low_temp_f: 68
max_pop_percent: 70
max_pop_window: "2 PM-6 PM"
measurable_qpf_total_in: 0.35
max_hourly_qpf_in: 0.12
first_precip_hour: "1 PM"
last_precip_hour: "8 PM"
thunder_mentioned: true
max_wind_gust_mph: 28
heat_index_max_f: 91
derived_daypart_summaries:
morning:
temp_range_f: "70-78"
max_pop_percent: 20
dominant_condition: Partly sunny
afternoon:
temp_range_f: "82-86"
max_pop_percent: 70
dominant_condition: Showers and thunderstorms likely
alert_digest:
checked: true
active_count: 0
relevant_count: 0
area_forecast_discussion:
key_messages:
- Scattered storms are possible this afternoon.
short_term: Showers and storms increase during the afternoon.
long_term: Periodic rain chances continue into the weekend.
weather_story:
available: true
title: Several Chances for Rain Through Monday
description: Scattered showers and thunderstorms remain possible.
recent_changes:
items: []
```
Field names should include units where the unit is not obvious:
`high_temp_f`, `max_pop_percent`, `measurable_qpf_total_in`,
`max_wind_gust_mph`, and similar names are preferred over ambiguous generic
names. Time and range strings should be formatted for prompt readability, while
machine-oriented timestamps should remain available in report metadata.
The QPF fields in the example are target output fields for a future upstream
source. They should not be treated as immediately implementable from the
current weatherfeeder-backed `CollectedFacts` sources. Until an upstream QPF
source exists, QPF fields should be omitted rather than fabricated from
precipitation probability or narrative text.
## Architecture Target
Keep the current package boundaries:
- `internal/forecast` owns normalized source data, deterministic forecast
derivation, period slicing, daypart grouping, and weather-signal calculations.
- `internal/briefing` owns prompt-facing module builders and module output
schemas.
- `internal/report` owns report identity, prompt ID, valid-period resolution,
output naming, comparison strategy, and default module composition.
- `internal/config` owns optional report module composition overrides.
- `internal/promptinput` owns final data-package assembly, validation, and
prompt-facing serialization.
- `internal/app` remains orchestration: resolve report, fetch sources, build
module context, execute configured modules, persist artifacts, run
Scriptorium, and notify distributor.
Do not move source fetching, subprocess execution, distributor upload behavior,
or raw external dependency types into module code.
## Layered Fact Contracts
Introduce explicit internal contracts for three layers:
1. `CollectedFacts`
2. `DerivedFacts`
3. `ModuleOutput`
`CollectedFacts` are normalized upstream inputs collected once per report run.
They should be broad and source-oriented, but not tied to Weather API transport
details. Examples include current conditions, observations, hourly forecast
runs, narrative forecast runs, active alerts, AFD discussion, weather story,
future radar inputs, and future historical observation totals.
`DerivedFacts` are reusable, report-scoped deterministic products calculated
from `CollectedFacts`. They may slice, combine, group, or summarize collected
facts when the result is broadly useful to more than one module or needed for
consistent behavior across modules. Examples include valid-period hourly
periods, valid-period narrative periods, alert overlaps, daily summaries,
configured daypart summaries, and reusable precipitation timing windows.
`ModuleOutput` is the prompt-facing output contract produced by one module. A
module may pass through raw-ish facts, such as AFD text, or expose derived
facts, such as daily summary fields. In both cases, the module owns the named
stanza shape and should produce stable, readable, unit-explicit prompt fields.
Unless implementation discovers a strong reason otherwise, the internal
contract for accessing `CollectedFacts` and `DerivedFacts` should have the same
shape:
- typed Go structs with named fields;
- nil pointers, empty slices, or zero values to represent absent facts;
- no `map[string]any` or string-keyed fact lookup as the primary API;
- immutable-by-convention values once passed to modules;
- helper methods only for repeated access patterns that would otherwise be
error-prone;
- source provenance and warnings stored separately from the primary fact
values, available to metadata/source-warning modules but not required by
ordinary modules.
Illustrative shape:
```go
type CollectedFacts struct {
Current *weatherdata.CurrentConditions
Observations *weatherdata.ObservationRun
Alerts *weatherdata.AlertRun
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Discussion *weatherdata.Discussion
WeatherStory *weatherdata.WeatherStory
// Future: radar, historical observations, snow/rain totals, etc.
}
type DerivedFacts struct {
HourlyPeriods []weatherdata.ForecastPeriod
NarrativePeriods []weatherdata.ForecastPeriod
AlertOverlaps []weatherdata.AlertOverlap
DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary
PrecipTiming *forecast.PrecipTiming
}
type ModuleContext struct {
Report report.Resolved
Collected CollectedFacts
Derived DerivedFacts
Units string
Timezone string
Location *LocationContext
}
```
The exact package names may differ during implementation. The important
boundary is semantic: `CollectedFacts` represent upstream facts after
normalization; `DerivedFacts` represent reusable report-scoped transformations;
modules represent prompt-facing stanza construction.
### DerivedFacts Boundary
Be conservative about what belongs in `DerivedFacts`. Add a value to this layer
only when it is:
- deterministic;
- report-scoped;
- reusable by multiple modules or needed to keep modules consistent;
- independent of prompt wording and presentation decisions.
`DerivedFacts` may:
- slice source periods to the report valid period;
- group hourly data into configured dayparts;
- compute reusable summaries;
- compute alert overlaps;
- normalize repeated time-window selections.
`DerivedFacts` should not:
- decide prompt-facing wording;
- decide which facts are important for one module only;
- format prose-like strings for the LLM;
- fetch upstream data;
- write artifacts;
- depend on Scriptorium or distributor.
Module-specific calculations should remain inside the module when they are
presentation-specific, used by only one module, or likely to change while
tuning prompt behavior.
## Module Model
Introduce a typed module model rather than generic maps. A module should have:
- stable module ID;
- self-contained output struct;
- one focused builder function;
- fixture or unit tests near the module;
- declared input requirements, such as hourly forecast, alerts, discussion, or
weather story;
- deterministic handling for missing optional source data;
- prompt-facing field names that are stable and unit-explicit.
The implementation may use a simple function registry rather than a broad
interface if that is enough:
```go
type ModuleID string
type ModuleBuilder func(ModuleContext) (ModuleOutput, error)
```
`ModuleOutput` should include the stable module ID, the YAML stanza name, and a
typed value owned by the module:
```go
type ModuleOutput struct {
ID ModuleID
StanzaName string
Value any
}
```
The module registry should preserve output order from report composition, but
the serialized YAML should use named stanzas for clarity.
Each module should be able to produce exactly one named stanza. If one source
can usefully feed multiple stanzas, split that into multiple modules rather than
making one module produce unrelated output.
## Report Composition Target
Report definitions should declare default ordered module IDs. Configuration may
override the ordered module list for implemented reports.
Illustrative future config shape:
```yaml
reports:
next_6_hours:
deterministic_modules:
- hourly_table
- precip_timing
- alert_digest
- afd_short_term_text
- weather_story_text
- spc_products
daily:
deterministic_modules:
- current_conditions
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- forecast_delta
- afd_short_term_text
- weather_story_text
- spc_products
```
Configuration should validate unknown module IDs, duplicate module IDs when
duplicates are not meaningful, and modules that are incompatible with the
selected report period. Defaults should remain in Go so the application works
without report composition config.
## Clean-Break Cutover Policy
This project is still pre-release. Prefer a direct cutover to the new internal
shape instead of preserving transitional report-shaped briefing structures.
Implementation should:
- replace report-shaped briefing containers with module-oriented snapshots;
- replace JSON prompt package output with YAML prompt package output;
- update inspect commands, Recent Changes, tests, and docs in the same cutover;
- remove obsolete `Daily`, `ThreeDay`, `Weekend`, and `Storm` briefing
container shapes when no longer needed;
- avoid compatibility aliases unless they materially reduce implementation
risk inside one stage.
The public CLI command names, report IDs, prompt IDs, RunID format, managed
Markdown report paths, and distributor upload source should remain stable unless
a separate roadmap explicitly changes them.
## Artifact And State Target
The durable artifacts should reflect the new module-oriented model.
Recommended target:
- module snapshot artifact: structured JSON for stable inspection, state
lookup, and Recent Changes comparisons;
- prompt data package artifact: YAML with named stanzas, passed to Scriptorium
as `data_package`;
- metadata artifact: JSON linking the module snapshot, YAML data package,
preflight output, rendered report, source warnings, source hashes, and
distributor notification artifact when present.
The workspace path names should make the artifact type clear. A future
implementation may keep the existing `data-packages/` directory, but file
extensions and metadata fields should reflect the real format, such as:
```text
workspace/
snapshots/<artifact_group>/<valid_date>/<run_id>.modules.json
data-packages/<artifact_group>/<valid_date>/<run_id>.data_package.yaml
```
Replace `inspect briefing` with `inspect modules` during the cutover.
`inspect modules` should return the module snapshot. `inspect data-package`
should return the YAML artifact or a parsed representation of the YAML artifact.
Do not leave inspect commands pointed at obsolete report-shaped data.
## Module Options And Compatibility
Each module should have a typed options struct, even when initially empty.
Configuration may decode module options from YAML, but internal module builders
should receive typed options rather than `map[string]any`.
Illustrative config shape:
```yaml
reports:
next_6_hours:
deterministic_modules:
- id: hourly_table
options:
range: valid_period
fields:
- time
- temperature_f
- pop_percent
- wind_gust_mph
- id: afd_short_term_text
```
Module definitions should declare:
- module ID;
- stanza name;
- typed options schema;
- supported report IDs or report categories;
- required collected facts;
- required derived facts;
- whether missing optional facts omit the stanza, emit an empty stanza, or
produce a warning;
- whether duplicate use of the module is allowed.
Configuration validation should reject:
- unknown report IDs;
- unknown module IDs;
- duplicate module IDs unless explicitly allowed;
- two modules that render the same stanza name;
- module options that do not match the module's typed option schema;
- modules that are incompatible with the report's valid-period strategy or
available facts.
## Recent Changes Target
Recent Changes must remain structured and deterministic. During the clean-break
cutover, move comparison inputs away from report-shaped `briefing.Package`
values and toward module-oriented snapshots.
Recommended target:
- compare `ModuleOutput` values or typed module snapshot stanzas, not rendered
YAML and not rendered Markdown;
- keep report-compatible matching policy in `internal/report`;
- keep threshold configuration in `internal/config`;
- keep comparison algorithms in `internal/changes`;
- make each comparison explicit about which module stanzas it needs.
For example, Daily comparison should primarily consume
`derived_daily_summary`, `derived_daypart_summaries`, `alert_digest`, and
`precip_timing` if present. If a required stanza is missing, the comparison
should return no change with an inspectable warning or an actionable error,
depending on the report's configured missing-data policy.
## Package Naming Target
The current `internal/forecast` package owns both forecast-specific derivation
and broader normalized weather data. Because planned sources include current
observations, radar, and historical review inputs, implementation should
consider splitting names during the clean-break refactor:
- `internal/weatherdata`: normalized collected source facts, source metadata,
warnings, and broad weather data types;
- `internal/forecast`: forecast-specific algorithms such as period slicing,
daily summaries, daypart grouping, and precipitation timing.
If this split is too large for the first cutover, introduce `CollectedFacts` in
the package that minimizes churn, but avoid expanding the meaning of
`internal/forecast` further in new module contracts.
## Initial Module Candidates
The first module catalog should start with the modules needed to replace current
report-shaped briefing output and should clearly distinguish implemented
modules from future-only candidates.
Initial candidates:
- `metadata`
- `current_conditions`
- `derived_daily_summary`
- `derived_daypart_summaries`
- `hourly_table`
- `precip_timing`
- `alert_digest`
- `area_forecast_discussion`
- `afd_key_messages`
- `afd_short_term_text`
- `afd_long_term_text`
- `weather_story`
- `forecast_delta`
- `outdoor_windows`
- `weekend_planning`
- `storm_window_summary`
Each module should declare inputs, outputs, report applicability,
missing-data behavior, compatibility behavior, and options.
## Target Derived Daily Summary
The intended `derived_daily_summary` shape is:
```yaml
derived_daily_summary:
high_temp_f: 86
low_temp_f: 68
max_pop_percent: 70
max_pop_window: "2 PM-6 PM"
measurable_qpf_total_in: 0.35
max_hourly_qpf_in: 0.12
first_precip_hour: "1 PM"
last_precip_hour: "8 PM"
thunder_mentioned: true
max_wind_gust_mph: 28
heat_index_max_f: 91
```
`measurable_qpf_total_in` and `max_hourly_qpf_in` are future target fields.
They require a real upstream quantitative precipitation source and should be
omitted until such a source is represented in `CollectedFacts`.
## Target Derived Daypart Summaries
The intended `derived_daypart_summaries` shape is:
```yaml
derived_daypart_summaries:
morning:
temp_range_f: "70-78"
max_pop_percent: 20
dominant_condition: Partly sunny
afternoon:
temp_range_f: "82-86"
max_pop_percent: 70
dominant_condition: Showers and thunderstorms likely
```
## Configurable Composition Target
The target configuration model should allow report module composition to be
changed without editing cross-cutting report-builder code. Built-in defaults
should remain in Go so the application works with no module override config.
Illustrative config:
```yaml
reports:
daily:
deterministic_modules:
- current_conditions
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- afd_short_term_text
- weather_story_text
```
Adding or removing an implemented module from an implemented report should be a
single config edit. Unknown modules, invalid options, duplicate stanzas, and
incompatible report/module combinations should fail with actionable errors.
## Acceptance Criteria
The refactor is complete when:
- current implemented reports generate successfully from named-stanza YAML
prompt packages;
- module snapshots are persisted as structured JSON and linked from metadata;
- `inspect modules` returns module snapshots;
- `inspect briefing` is removed from CLI help, parser support, and
non-roadmap docs;
- Recent Changes compares structured module snapshots, not rendered Markdown or
YAML text;
- report definitions declare default module order in one place;
- implemented report module composition can be overridden by config;
- implemented modules have typed options and compatibility contracts;
- `CollectedFacts` are built once per report run and reused by all modules;
- `DerivedFacts` are built from `CollectedFacts` and do not depend on adapter
transport details;
- module builders do not call Weather API, Scriptorium, distributor, or
filesystem state directly;
- stale report-shaped briefing containers are removed from the generation path;
- QPF output fields remain omitted until upstream QPF exists.
## Design Rules
- Keep modules deterministic.
- Keep modules self-contained where practical.
- Preserve the `CollectedFacts -> DerivedFacts -> ModuleOutput` boundary.
- Build `CollectedFacts` once per report run.
- Build `DerivedFacts` from `CollectedFacts`, not from adapter-specific
transport details.
- Keep `DerivedFacts` conservative and reusable.
- Keep raw external source details behind adapters and forecast normalization.
- Keep report composition centralized and ordered.
- Prefer typed outputs over generic maps.
- Prefer typed fact contracts over string-keyed fact registries.
- Prefer named YAML stanzas over generic module arrays.
- Use unit-explicit field names.
- Do not require the LLM to calculate obvious derived facts.
- Do not let module builders call external services or write durable state.
- Do not introduce plugins, dynamic loading, or a generic workflow engine.
## Risks And Mitigations
- Prompt contract churn: stage YAML introduction after module outputs are
tested and inspectable.
- Recent Changes drift: compare stable module outputs and keep snapshot tests.
- Over-abstraction: start with simple builders and a registry, not a framework.
- Config complexity: expose ordered module selection and typed options only for
implemented modules; defer broad parameterization.
- Loss of useful context: preserve focused source excerpts and source warnings,
but avoid reintroducing raw unbounded payloads.
## Deferred Work
These are out of scope for the initial module refactor:
- dynamic plugin loading;
- user-defined module code;
- YAML-defined module schemas;
- module parameterization beyond implemented typed options;
- replacing Weather API source fetching with module-owned fetches;
- moving prompt authoring or Scriptorium prompt changes into weatherreporter;
- adding future source modules, such as SPC products, before upstream data and
report requirements exist.
## Open Questions
### Should module snapshots and prompt packages both be persisted?
Recommended approach: persist module snapshots as JSON and prompt packages as
YAML. JSON module snapshots are better for structured Recent Changes, state
lookup, and tests. YAML prompt packages are better for prompt readability and
LLM consumption. Keeping both artifacts gives each use case the right format
without asking comparison code to parse prompt-oriented YAML.
Viable alternative: persist only the YAML prompt package and parse it for
inspection and Recent Changes. This reduces artifact count, but it couples
machine comparison to prompt formatting and makes future prompt-oriented
formatting changes riskier.
### Should `internal/forecast` be split during the first cutover?
Recommended approach: split broad normalized source types into
`internal/weatherdata` during the clean-break cutover if the implementation
scope remains manageable. This name fits current conditions, alerts,
discussion, weather story, future radar, and future historical data better than
`forecast`.
Viable alternative: keep existing `internal/forecast` types for the first
module implementation and introduce `CollectedFacts` as a wrapper. This reduces
short-term churn, but it leaves a package name that will become increasingly
misleading as non-forecast sources grow.
### Should module config support typed options immediately?
Recommended approach: support typed options for implemented modules from the
start, even if most modules use empty options. This establishes the extension
point needed for hourly ranges, field selection, and AFD section choices
without adding dynamic maps to module builders.
Viable alternative: initially support only ordered module IDs and add options
later. This is simpler, but it may force another config shape change as soon as
hourly range or section-selection experiments begin.

View File

@@ -72,11 +72,11 @@ reports:
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- forecast_delta
- id: area_forecast_discussion
options:
sections:
@@ -86,3 +86,4 @@ reports:
- long_term
- weather_story
- outdoor_windows
- hourly_forecast

View File

@@ -112,9 +112,6 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err
}
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
return nil, err
}
return builder.bundle, nil
}
@@ -267,15 +264,6 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := weatherdata.Source{
Name: sourceName,
FetchedAt: b.fetchedAt,
Missing: true,
}
return b.applyMissingPolicy(&source, "missing_source", message)
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true
if required {

View File

@@ -55,11 +55,11 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
if len(bundle.Sources) != 7 {
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
}
if len(bundle.Warnings) != 1 {
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
@@ -197,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
wantWarns int
wantSource bool
}{
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true},
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
{name: "error", policy: config.MissingSourceError, wantErr: true},
}

View File

@@ -827,13 +827,6 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
}
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

View File

@@ -160,10 +160,33 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
}
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(data), "recent_changes:") ||
!strings.Contains(string(data), "applicable_risk_products:") ||
!strings.Contains(string(data), "derived_summaries:") ||
!strings.Contains(string(data), "narrative_products:") ||
!strings.Contains(string(data), "raw_data:") ||
!strings.Contains(string(data), "current_conditions:") ||
!strings.Contains(string(data), "narrative_forecast:") ||
!strings.Contains(string(data), "hourly_forecast:") ||
!strings.Contains(string(data), "area_forecast_discussion:") {
t.Fatalf("data package missing expected content:\n%s", string(data))
}
if strings.Contains(string(data), "source_warnings:") {
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
}
riskIndex := strings.Index(string(data), " applicable_risk_products:")
derivedIndex := strings.Index(string(data), " derived_summaries:")
narrativeIndex := strings.Index(string(data), " narrative_products:")
rawIndex := strings.Index(string(data), " raw_data:")
alertIndex := strings.Index(string(data), " alert_digest:")
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
storyIndex := strings.Index(string(data), " weather_story:")
currentIndex := strings.Index(string(data), " current_conditions:")
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
!(riskIndex < alertIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
}
savedDataPackage, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("decode data package: %v", err)
@@ -178,6 +201,14 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !ok || current["condition_text"] != "Clear" {
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
}
narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any)
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
}
hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any)
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
}
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
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"])
@@ -665,7 +696,7 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
if err != nil {
t.Fatalf("decode daily summary: %v", err)
}
if !ok || dailySummary["date"] != "2026-05-30" {
if !ok || dailySummary["date"] != "Saturday, May 30, 2026" {
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
}
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
@@ -954,8 +985,11 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
if err != nil {
t.Fatalf("InspectSources() error = %v", err)
}
if len(sources.Sources) == 0 || len(sources.Warnings) == 0 {
t.Fatalf("sources = %#v, want provenance and warnings", sources)
if len(sources.Sources) == 0 {
t.Fatalf("sources = %#v, want provenance", sources)
}
if len(sources.Warnings) != 0 {
t.Fatalf("sources warnings = %#v, want none for complete fetched sources", sources.Warnings)
}
}
@@ -1342,11 +1376,12 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"low_temp_f": low,
"high_temp_f": high,
"max_pop_percent": precip,
"daily_precipitation_probability": 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)},
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"),
"temp_range_f": "50-58",
},
}},
@@ -1368,7 +1403,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
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")},
"date": date,
"period": date + " at 6:00 AM to " + date + " at 10:00 AM",
"temp_range_f": "50-58",
"max_pop_percent": precip,
"max_pop_time": "6 AM",

View File

@@ -0,0 +1,60 @@
package briefing
import (
"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/weatherdata"
)
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"`
}
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 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
}

View File

@@ -0,0 +1,67 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
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"`
}
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
}

View File

@@ -1,269 +0,0 @@
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

@@ -24,6 +24,8 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
}{
{id: module.Metadata, stanza: "metadata"},
{id: module.CurrentConditions, stanza: "current_conditions"},
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
{id: module.HourlyForecast, stanza: "hourly_forecast"},
{id: module.AlertDigest, stanza: "alert_digest"},
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
{id: module.WeatherStory, stanza: "weather_story"},
@@ -44,6 +46,114 @@ func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
}
}
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
}
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
}
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
}
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
}
}
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[NarrativeForecastModule](t, output)
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
}
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
}
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
}
if period.WindDirection != "NE" {
t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal narrative forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
}
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
}
if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
}
}
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -87,16 +197,22 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
}
if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
}
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"} {
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText)
}
}
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
@@ -130,10 +246,14 @@ func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Current = nil
ctx.Collected.Narrative = nil
ctx.Collected.Hourly = nil
ctx.Derived.ValidPeriodNarrativePeriods = nil
ctx.Derived.ValidPeriodHourlyPeriods = nil
ctx.Collected.Discussion = nil
ctx.Collected.WeatherStory = nil
for _, id := range []module.ID{module.CurrentConditions, module.AreaForecastDiscussion, module.WeatherStory} {
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
if err != nil {
t.Fatalf("BuildModule(%s) error = %v", id, err)
@@ -212,6 +332,14 @@ func testModuleContext() ModuleContext {
humidity := 71.0
windMph := 8.0
windDirection := 190.0
narrativeTempF := 81.0
narrativePop := 60.0
narrativeWind := 12.0
narrativeWindDirection := 45.0
hourlyTempF := 76.0
hourlyPop := 70.0
hourlyHumidity := 66.0
hourlyWindMph := 14.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{
Resolved: resolved,
@@ -225,6 +353,50 @@ func testModuleContext() ModuleContext {
WindSpeedMph: &windMph,
WindDirectionDegrees: &windDirection,
},
Narrative: &weatherdata.ForecastRun{
LocationID: "test-grid",
LocationName: "Testville",
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
UpdatedAt: &updatedAt,
Product: "narrative",
Periods: []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: &isDay,
TextDescription: "Morning storms, then partly sunny.",
TemperatureF: floatPtr(narrativeTempF),
WindSpeedMph: &narrativeWind,
WindDirectionDegrees: &narrativeWindDirection,
ProbabilityOfPrecipitationPercent: &narrativePop,
},
},
},
Hourly: &weatherdata.ForecastRun{
LocationID: "test-grid",
LocationName: "Testville",
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
UpdatedAt: &updatedAt,
Product: "hourly",
Periods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
{
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
TextDescription: "Tomorrow showers.",
},
},
},
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
}},
@@ -257,6 +429,31 @@ func testModuleContext() ModuleContext {
}},
},
Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
},
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: &isDay,
TextDescription: "Morning storms, then partly sunny.",
TemperatureF: floatPtr(narrativeTempF),
WindSpeedMph: &narrativeWind,
WindDirectionDegrees: &narrativeWindDirection,
ProbabilityOfPrecipitationPercent: &narrativePop,
},
},
AlertOverlaps: []forecast.AlertOverlap{{
Event: "Flood Watch",
Headline: "Flooding possible",

View File

@@ -0,0 +1,58 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
}
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),
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
}
if value.isEmpty() {
return nil, nil
}
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
}
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.WindDirection == ""
}

View File

@@ -0,0 +1,153 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DerivedDailySummaryModule struct {
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_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"`
}
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 derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
value := DerivedDailySummaryModule{
Date: friendlyDateLabel(summary.Date, timezone),
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
for _, daypart := range summary.Dayparts {
addRange(&temperature, daypart.Temperature)
addRange(&apparent, daypart.ApparentTemperature)
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
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{}{}
}
}
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
if narrativeTemperature.Max != nil {
value.HighTempF = roundedInt(narrativeTemperature.Max)
} else {
value.HighTempF = roundedInt(temperature.Max)
}
if narrativeTemperature.Min != nil {
value.LowTempF = roundedInt(narrativeTemperature.Min)
} else {
value.LowTempF = roundedInt(temperature.Min)
}
value.HeatIndexMaxF = roundedInt(apparent.Max)
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
if narrativePrecipitation != nil {
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
} else if maxPop != nil {
value.DailyPrecipitationProbability = roundedInt(&maxPop.Value)
}
if maxPop != nil {
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
}
if maxGust != nil {
value.MaxWindGustMph = roundedInt(&maxGust.Value)
}
value.DominantConditions = sortedSet(conditions)
value.Hazards = sortedSet(hazards)
return value, nil
}
func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range {
var out forecast.Range
for _, period := range periods {
addNarrativeHigh(&out, period.TemperatureFMax)
addNarrativeLow(&out, period.TemperatureFMin)
if period.TemperatureF != nil && period.IsDay != nil {
if *period.IsDay {
addNarrativeHigh(&out, period.TemperatureF)
} else {
addNarrativeLow(&out, period.TemperatureF)
}
}
}
return out
}
func addNarrativeHigh(target *forecast.Range, value *float64) {
if value == nil {
return
}
if target.Max == nil || *value > *target.Max {
copied := *value
target.Max = &copied
}
}
func addNarrativeLow(target *forecast.Range, value *float64) {
if value == nil {
return
}
if target.Min == nil || *value < *target.Min {
copied := *value
target.Min = &copied
}
}
func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue {
var maxPop *forecast.TimedValue
for _, period := range periods {
if period.ProbabilityOfPrecipitationPercent == nil {
continue
}
value := forecast.TimedValue{
Value: *period.ProbabilityOfPrecipitationPercent,
Time: period.StartTime,
}
maxTimedValue(&maxPop, &value)
}
return maxPop
}
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
if maxPop == nil || maxPop.Value <= 0 {
return ""
}
percent := roundedInt(&maxPop.Value)
if percent == nil {
return ""
}
return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone))
}

View File

@@ -0,0 +1,108 @@
package briefing
import (
"fmt"
"strings"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
}
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 derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone),
Period: friendlyPeriodLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition,
NotableConditions: append([]string(nil), daypart.NotableConditions...),
Snow: daypart.Indicators.Snow,
Ice: daypart.Indicators.Ice,
Fog: daypart.Indicators.Fog,
Heat: daypart.Indicators.Heat,
Cold: daypart.Indicators.Cold,
Wind: daypart.Indicators.Wind,
RelevantAlertCount: len(daypart.AlertOverlaps),
}
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 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(), "_")
}

View File

@@ -1,330 +0,0 @@
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

@@ -24,14 +24,20 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
}
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.Date != "Friday, May 29, 2026" {
t.Fatalf("Date = %q, want friendly local date", value.Date)
}
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.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
}
if value.FirstPrecipHour != "8 AM" || value.LastPrecipHour != "1 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want morning through afternoon thunder", value)
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
}
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
}
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
}
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
@@ -44,16 +50,43 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
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"} {
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily json = %s, want field %s", jsonText, field)
}
}
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
if strings.Contains(jsonText, removed) {
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
}
}
if strings.Contains(jsonText, "qpf") {
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
}
}
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
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 fallback 96/31", value.HighTempF, value.LowTempF)
}
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 {
t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability)
}
if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") {
t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions)
}
}
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
@@ -63,8 +96,27 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
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)
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
}
if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
}
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
}
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal precip timing: %v", err)
}
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
}
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
}
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
@@ -73,8 +125,8 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
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 len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
}
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
@@ -98,6 +150,9 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
}
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
}
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)
@@ -111,11 +166,14 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
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"} {
for _, field := range []string{"date", "period", "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)
}
}
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
@@ -191,10 +249,33 @@ func derivedModuleContext(id report.ID) ModuleContext {
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-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10),
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),
derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12),
}
narrative := []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: boolPtr(true),
TextDescription: "Morning storms, then partly sunny.",
TemperatureFMax: floatPtr(88),
ProbabilityOfPrecipitationPercent: floatPtr(55),
},
{
Name: "Tonight",
StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
IsDay: boolPtr(false),
TextDescription: "Clouds linger tonight.",
TemperatureFMin: floatPtr(64),
ProbabilityOfPrecipitationPercent: floatPtr(30),
},
}
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
return ModuleContext{
Resolved: report.Resolved{
Definition: definition,
@@ -202,8 +283,16 @@ func derivedModuleContext(id report.ID) ModuleContext {
Timezone: "America/Chicago",
ValidPeriod: summary.Period,
},
Collected: facts.CollectedFacts{
Narrative: &weatherdata.ForecastRun{
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
Product: "narrative",
Periods: append([]weatherdata.ForecastPeriod(nil), narrative...),
},
},
Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: hours,
ValidPeriodNarrativePeriods: narrative,
DailySummaries: []forecast.DailySummary{summary},
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
PrecipTiming: forecast.BuildPrecipTiming(hours),
@@ -238,3 +327,16 @@ func derivedHour(start string, text string, precip float64, temperature float64,
func floatPtr(value float64) *float64 {
return &value
}
func boolPtr(value bool) *bool {
return &value
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}

View File

@@ -0,0 +1,123 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type HourlyForecastModule struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
}
type HourlyForecastPeriod struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
UVIndex *float64 `json:"uv_index,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
}
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
hourly := ctx.Collected.Hourly
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
return nil, nil
}
value := HourlyForecastModule{
Product: hourly.Product,
IssuedAt: hourly.IssuedAt,
UpdatedAt: copyTime(hourly.UpdatedAt),
SourceLocation: hourly.LocationName,
SourceLocationID: hourly.LocationID,
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
}
if value.isEmpty() {
return nil, nil
}
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
}
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods {
out = append(out, HourlyForecastPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),
TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
TemperatureFMin: copyFloat(period.TemperatureFMin),
TemperatureCMax: copyFloat(period.TemperatureCMax),
TemperatureFMax: copyFloat(period.TemperatureFMax),
DewpointC: copyFloat(period.DewpointC),
DewpointF: copyFloat(period.DewpointF),
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph),
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
VisibilityMeters: copyFloat(period.VisibilityMeters),
VisibilityMiles: copyFloat(period.VisibilityMiles),
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
UVIndex: copyFloat(period.UVIndex),
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
})
}
return out
}
func (v HourlyForecastModule) isEmpty() bool {
return v.Product == "" &&
v.IssuedAt.IsZero() &&
v.UpdatedAt == nil &&
v.SourceLocation == "" &&
v.SourceLocationID == "" &&
len(v.Periods) == 0
}

View File

@@ -0,0 +1,64 @@
package briefing
import (
"time"
"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"`
}
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 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
}

View File

@@ -0,0 +1,128 @@
package briefing
import (
"fmt"
"math"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
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 windDirectionLabel(degrees *float64) string {
if degrees == nil {
return ""
}
labels := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}
normalized := math.Mod(*degrees, 360)
if normalized < 0 {
normalized += 360
}
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
return labels[sector]
}
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil {
return ""
}
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 friendlyPeriodLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
return ""
}
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
}
func friendlyDateTimeLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func friendlyDateLabel(date string, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
if err != nil {
return date
}
return parsed.Format("Monday, January 2, 2006")
}
func localDateLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format(timeutil.DateLayout)
}
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,29 @@
package briefing
import "testing"
func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
tests := []struct {
name string
degrees *float64
want string
}{
{name: "nil", degrees: nil, want: ""},
{name: "north", degrees: floatPtr(0), want: "N"},
{name: "below first boundary", degrees: floatPtr(11.24), want: "N"},
{name: "at first boundary", degrees: floatPtr(11.25), want: "NNE"},
{name: "northeast", degrees: floatPtr(45), want: "NE"},
{name: "south", degrees: floatPtr(180), want: "S"},
{name: "wrap to north", degrees: floatPtr(348.75), want: "N"},
{name: "full rotation", degrees: floatPtr(360), want: "N"},
{name: "negative normalizes", degrees: floatPtr(-45), want: "NW"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := windDirectionLabel(tt.degrees); got != tt.want {
t.Fatalf("windDirectionLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package briefing
import (
"fmt"
"reflect"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -61,6 +62,12 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
if _, ok := registry.definitions[definition.ID]; ok {
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
}
if definition.Builder == nil {
return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID)
}
if definition.MissingData == module.MissingDataWarn {
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
}
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)
}
@@ -92,6 +99,20 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
if definition.Builder == nil {
return nil, fmt.Errorf("module %q has no builder", item.ID)
}
missing := missingRequirements(definition, ctx)
if len(missing) > 0 {
switch definition.MissingData {
case module.MissingDataOmit:
return nil, nil
case module.MissingDataError:
return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", "))
case module.MissingDataEmpty:
case module.MissingDataWarn:
return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData)
default:
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
}
}
options := item.Options
if options == nil {
options = definition.DefaultOptions
@@ -112,6 +133,61 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
return output, nil
}
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
var missing []string
for _, requirement := range definition.RequiredCollected {
if !collectedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
}
}
for _, requirement := range definition.RequiredDerived {
if !derivedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
}
}
return missing
}
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
switch requirement {
case module.CollectedCurrentConditions:
return ctx.Collected.Current != nil
case module.CollectedNarrativeForecast:
return ctx.Collected.Narrative != nil
case module.CollectedHourlyForecast:
return ctx.Collected.Hourly != nil
case module.CollectedAlerts:
return ctx.Collected.Alerts != nil
case module.CollectedDiscussion:
return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil
case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
return false
}
}
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
switch requirement {
case module.RequiresDerivedHourlyPeriods:
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
case module.RequiresDerivedNarrativePeriods:
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
case module.RequiresDerivedAlertOverlaps:
return true
case module.RequiresDerivedDailySummaries:
return len(ctx.Derived.DailySummaries) > 0
case module.RequiresDerivedDaypartSummaries:
return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming:
return true
default:
return false
}
}
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
seenModules := map[module.ID]struct{}{}
seenStanzas := map[string]module.ID{}
@@ -190,6 +266,26 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule,
},
{
ID: module.NarrativeForecast,
StanzaName: "narrative_forecast",
DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule,
},
{
ID: module.HourlyForecast,
StanzaName: "hourly_forecast",
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
},
{
ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary",
@@ -208,14 +304,6 @@ func defaultModuleDefinitions() []ModuleDefinition {
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",
@@ -253,13 +341,6 @@ func defaultModuleDefinitions() []ModuleDefinition {
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",
@@ -278,21 +359,5 @@ func defaultModuleDefinitions() []ModuleDefinition {
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

@@ -14,6 +14,41 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
}
for _, item := range definition.Modules {
moduleDefinition, err := registry.Lookup(item.ID)
if err != nil {
t.Fatalf("Lookup(%s) error = %v", item.ID, err)
}
if moduleDefinition.Builder == nil {
t.Fatalf("report %s module %s has no builder", definition.ID, item.ID)
}
}
}
}
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, definition := range report.DefaultRegistry().All() {
t.Run(string(definition.ID), func(t *testing.T) {
ctx := derivedModuleContext(definition.ID)
var outputs []module.Output
for _, item := range definition.Modules {
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
}
if output != nil {
outputs = append(outputs, *output)
}
}
snapshot, err := module.NewSnapshot(outputs)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
if len(snapshot.Outputs) == 0 {
t.Fatal("snapshot outputs = 0, want default report modules")
}
})
}
}
@@ -38,8 +73,8 @@ func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}},
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, Builder: noopModuleBuilder},
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
})
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
t.Fatalf("error = %v, want duplicate stanza name", err)
@@ -48,12 +83,30 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
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"`) {
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
t.Fatalf("error = %v, want incompatible report", err)
}
}
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
})
if err == nil || !strings.Contains(err.Error(), `module "metadata" has no builder`) {
t.Fatalf("error = %v, want missing builder", err)
}
}
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
})
if err == nil || !strings.Contains(err.Error(), `unsupported missing data behavior`) {
t.Fatalf("error = %v, want unsupported missing-data behavior", err)
}
}
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
@@ -74,3 +127,7 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
t.Fatalf("ValidateComposition() error = %v", err)
}
}
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
}

View File

@@ -0,0 +1,91 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type NarrativeForecastModule struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []NarrativeForecastPeriod `json:"periods,omitempty"`
}
type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
}
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
narrative := ctx.Collected.Narrative
if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 {
return nil, nil
}
value := NarrativeForecastModule{
Product: narrative.Product,
IssuedAt: narrative.IssuedAt,
UpdatedAt: copyTime(narrative.UpdatedAt),
SourceLocation: narrative.LocationName,
SourceLocationID: narrative.LocationID,
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
}
if value.isEmpty() {
return nil, nil
}
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
}
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods {
out = append(out, NarrativeForecastPeriod{
Name: period.Name,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
TemperatureFMin: copyFloat(period.TemperatureFMin),
TemperatureCMax: copyFloat(period.TemperatureCMax),
TemperatureFMax: copyFloat(period.TemperatureFMax),
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph),
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
})
}
return out
}
func (v NarrativeForecastModule) isEmpty() bool {
return v.Product == "" &&
v.IssuedAt.IsZero() &&
v.UpdatedAt == nil &&
v.SourceLocation == "" &&
v.SourceLocationID == "" &&
len(v.Periods) == 0
}

View File

@@ -0,0 +1,38 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
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"`
}
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 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,
}
}

View File

@@ -96,6 +96,14 @@ func copyBool(value *bool) *bool {
return &copied
}
func copyInt(value *int) *int {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyFloat(value *float64) *float64 {
if value == nil {
return nil

View File

@@ -0,0 +1,49 @@
package briefing
import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type PrecipTimingModule struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
ProbabilityThreshold float64 `json:"probability_threshold"`
PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
}
type PrecipitationWindowModule struct {
Start string `json:"start"`
End string `json:"end,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
}
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 precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
value := PrecipTimingModule{
ProbabilityThreshold: timing.ProbabilityThreshold,
ThunderMentioned: timing.ThunderMentioned,
}
if timing.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
}
for _, window := range timing.PrecipitationWindows {
item := PrecipitationWindowModule{
Start: clockLabel(window.Start, timezone),
}
if window.End != nil {
item.End = clockLabel(*window.End, timezone)
}
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
}
return value
}

View File

@@ -0,0 +1,24 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
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 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
}

View File

@@ -0,0 +1,42 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
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 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
}

View File

@@ -10,7 +10,6 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type Thresholds struct {
@@ -64,7 +63,7 @@ func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds
var changes []Change
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...)
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
if previousHasTiming && currentHasTiming {
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
}
@@ -79,12 +78,13 @@ type dailySummaryStanza 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"`
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
}
type daypartSummaryStanza struct {
Period timeutil.Period `json:"period"`
Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`

View File

@@ -3,10 +3,8 @@ package changes
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
@@ -95,10 +93,10 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
Date: "2026-05-29",
HighTempF: &high,
LowTempF: &low,
MaxPopPercent: &precip,
DailyPrecipitationProbability: &precip,
}},
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},
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
}},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
@@ -114,10 +112,6 @@ func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
return snapshot
}
func period(start string, end string) timeutil.Period {
return timeutil.Period{Start: at(start), End: at(end)}
}
func testThresholds() Thresholds {
return Thresholds{
TemperatureDegrees: 5,
@@ -136,11 +130,3 @@ func countType(changes []Change, changeType string) int {
}
return count
}
func at(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -5,7 +5,6 @@ import (
"sort"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
@@ -113,10 +112,7 @@ func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[strin
}
func daypartDate(daypart daypartSummaryStanza) string {
if !daypart.Period.Start.IsZero() {
return daypart.Period.Start.Format(timeutil.DateLayout)
}
return ""
return daypart.Date
}
func minInt(a *int, b *int) *int {

View File

@@ -31,7 +31,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
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"),
Date: date,
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
TempRangeF: tempRange,
MaxPopPercent: &precip,
MaxPopTime: precipTime,

View File

@@ -736,8 +736,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
sourcesOutput = stdout.String()
}
}
if !strings.Contains(sourcesOutput, `"warnings"`) {
t.Fatalf("inspect sources output missing warnings:\n%s", sourcesOutput)
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
}
}

View File

@@ -196,10 +196,20 @@ reports:
reports:
daily:
deterministic_modules:
- storm_window_summary
- tomorrow_planning
`,
wantErr: `not compatible with report "daily_today"`,
},
{
name: "RemovedPlaceholderModule",
yaml: `
reports:
daily:
deterministic_modules:
- forecast_delta
`,
wantErr: `unknown module "forecast_delta"`,
},
{
name: "InvalidOptions",
yaml: `

View File

@@ -17,7 +17,7 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
}
collected := BuildCollected(bundle)
@@ -27,13 +27,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
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)
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("SourceWarnings = %#v, want discussion warning", collected.SourceWarnings)
}
bundle.Sources[0].Name = "changed"
bundle.Warnings[0].Source = "changed"
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" {
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
}
}
@@ -69,6 +69,12 @@ func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
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.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final closed window end", derived.PrecipTiming.LastPrecipitation)
}
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
t.Fatalf("PrecipitationWindows = %#v, want two threshold windows", derived.PrecipTiming.PrecipitationWindows)
}
if !derived.PrecipTiming.ThunderMentioned {
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
}

View File

@@ -47,6 +47,8 @@ type TimedValue struct {
Time time.Time `json:"time"`
}
const DefaultPrecipWindowProbabilityThreshold = 40
type Indicators struct {
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
@@ -69,31 +71,98 @@ type PrecipTiming struct {
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
ProbabilityThreshold float64 `json:"probabilityThreshold"`
PrecipitationWindows []PrecipitationWindow `json:"precipitationWindows,omitempty"`
ThunderMentioned bool `json:"thunderMentioned,omitempty"`
}
type PrecipitationWindow struct {
Start time.Time `json:"start"`
End *time.Time `json:"end,omitempty"`
MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"`
ProbabilityThreshold float64 `json:"probabilityThreshold"`
}
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,
return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold)
}
func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming {
timing := PrecipTiming{ProbabilityThreshold: threshold}
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
sort.SliceStable(sorted, func(i int, j int) bool {
return sorted[i].StartTime.Before(sorted[j].StartTime)
})
var active *PrecipitationWindow
var activeLastEnd time.Time
closeActive := func() {
if active == nil {
return
}
end := activeLastEnd
active.End = &end
timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active)
active = nil
}
startActive := func(forecastPeriod weatherdata.ForecastPeriod, probability float64) {
active = &PrecipitationWindow{
Start: forecastPeriod.StartTime,
MaxPrecipitationProbability: TimedValue{
Value: probability,
Time: forecastPeriod.StartTime,
},
ProbabilityThreshold: threshold,
}
activeLastEnd = forecastPeriod.EndTime
if timing.FirstPrecipitation == nil {
timing.FirstPrecipitation = &TimedValue{
Value: probability,
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
}
for _, forecastPeriod := range sorted {
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
if forecastPeriod.ProbabilityOfPrecipitationPercent == nil || *forecastPeriod.ProbabilityOfPrecipitationPercent < threshold {
closeActive()
} else {
probability := *forecastPeriod.ProbabilityOfPrecipitationPercent
if active == nil {
startActive(forecastPeriod, probability)
} else {
if forecastPeriod.StartTime.After(activeLastEnd) {
closeActive()
startActive(forecastPeriod, probability)
}
if probability > active.MaxPrecipitationProbability.Value {
active.MaxPrecipitationProbability = TimedValue{
Value: probability,
Time: forecastPeriod.StartTime,
}
}
if forecastPeriod.EndTime.After(activeLastEnd) {
activeLastEnd = forecastPeriod.EndTime
}
}
}
if mentionsThunder(forecastPeriod.TextDescription) {
timing.ThunderMentioned = true
}
}
if active != nil {
timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active)
}
if len(timing.PrecipitationWindows) > 0 {
final := timing.PrecipitationWindows[len(timing.PrecipitationWindows)-1]
if final.End != nil {
timing.LastPrecipitation = &TimedValue{
Value: final.MaxPrecipitationProbability.Value,
Time: *final.End,
}
}
}
return timing
}

View File

@@ -210,31 +210,94 @@ func TestAlertOverlap(t *testing.T) {
}
}
func TestBuildPrecipTimingTracksRainAndThunder(t *testing.T) {
func TestBuildPrecipTimingBuildsThresholdWindows(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),
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Brief lull", 70, nil, ptr(39.999), nil, nil),
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Unknown rain chance", 70, nil, nil, nil, nil),
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(40), 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)
t.Fatalf("FirstPrecipitation = %#v, want first threshold window start", 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.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
t.Fatalf("LastPrecipitation = %#v, want final closed threshold window end", timing.LastPrecipitation)
}
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
}
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
}
if len(timing.PrecipitationWindows) != 2 {
t.Fatalf("PrecipitationWindows length = %d, want 2: %#v", len(timing.PrecipitationWindows), timing.PrecipitationWindows)
}
first := timing.PrecipitationWindows[0]
if first.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || first.End == nil || first.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
t.Fatalf("first window = %#v, want 9-11 AM", first)
}
if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability)
}
second := timing.PrecipitationWindows[1]
if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
t.Fatalf("second window = %#v, want noon-1 PM", second)
}
if !timing.ThunderMentioned {
t.Fatal("ThunderMentioned = false, want true")
}
}
func TestBuildPrecipTimingLeavesFinalWindowOpen(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(45), nil, nil),
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
}
timing := BuildPrecipTiming(periods)
if len(timing.PrecipitationWindows) != 1 {
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
}
if timing.PrecipitationWindows[0].End != nil {
t.Fatalf("open window End = %v, want nil", timing.PrecipitationWindows[0].End)
}
if timing.LastPrecipitation != nil {
t.Fatalf("LastPrecipitation = %#v, want nil for open final window", timing.LastPrecipitation)
}
}
func TestBuildPrecipTimingSupportsNonDefaultThreshold(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", "Showers", 70, nil, ptr(50), nil, nil),
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Showers", 70, nil, ptr(55), nil, nil),
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Drying out", 70, nil, ptr(20), nil, nil),
}
timing := buildPrecipTimingWithThreshold(periods, 55)
if timing.ProbabilityThreshold != 55 {
t.Fatalf("ProbabilityThreshold = %v, want 55", timing.ProbabilityThreshold)
}
if len(timing.PrecipitationWindows) != 1 {
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
}
window := timing.PrecipitationWindows[0]
if window.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || window.End == nil || window.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
t.Fatalf("window = %#v, want 9-11 AM", window)
}
}
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
periods := []weatherdata.ForecastPeriod{
@@ -243,9 +306,12 @@ func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
timing := BuildPrecipTiming(periods)
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || timing.ThunderMentioned {
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || len(timing.PrecipitationWindows) != 0 || timing.ThunderMentioned {
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
}
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
}
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
}

View File

@@ -13,18 +13,16 @@ type ID string
const (
Metadata ID = "metadata"
CurrentConditions ID = "current_conditions"
NarrativeForecast ID = "narrative_forecast"
HourlyForecast ID = "hourly_forecast"
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 {
@@ -108,6 +106,8 @@ type FactRequirement string
const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
@@ -118,7 +118,6 @@ const (
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedStormWindowSummary FactRequirement = "derived.storm_window_summary"
)
type MissingDataBehavior string
@@ -132,17 +131,15 @@ const (
type MetadataOptions struct{}
type CurrentConditionsOptions struct{}
type NarrativeForecastOptions struct{}
type HourlyForecastOptions 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

@@ -18,6 +18,35 @@ import (
const SchemaVersion = "weatherreporter.data_package.v2"
const (
metadataStanza = "metadata"
categoryApplicableRiskProducts = "applicable_risk_products"
categoryDerivedSummaries = "derived_summaries"
categoryNarrativeProducts = "narrative_products"
categoryRawData = "raw_data"
)
var briefingCategoryOrder = []string{
categoryApplicableRiskProducts,
categoryDerivedSummaries,
categoryNarrativeProducts,
categoryRawData,
}
var briefingStanzaCategories = map[string]string{
string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries,
string(module.OutdoorWindows): categoryDerivedSummaries,
string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): categoryRawData,
string(module.HourlyForecast): categoryRawData,
}
type BuildRequest struct {
Metadata Metadata
Modules module.Snapshot
@@ -144,13 +173,24 @@ func Validate(pkg Package) error {
if len(pkg.Briefing.Order) == 0 {
return fmt.Errorf("briefing stanzas are required")
}
seen := map[string]struct{}{}
for _, name := range pkg.Briefing.Order {
if name == "" {
return fmt.Errorf("briefing stanza name is required")
}
if _, ok := seen[name]; ok {
return fmt.Errorf("duplicate briefing stanza %q", name)
}
seen[name] = struct{}{}
if _, ok := pkg.Briefing.Values[name]; !ok {
return fmt.Errorf("briefing stanza %q is missing", name)
}
if name == metadataStanza {
continue
}
if _, ok := briefingStanzaCategories[name]; !ok {
return fmt.Errorf("briefing stanza %q has no prompt-input category", name)
}
}
return nil
}
@@ -191,17 +231,40 @@ func LoadYAML(data []byte) (Package, error) {
func (b BriefingStanzas) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
categoryNames := map[string][]string{}
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)
if name == metadataStanza {
if err := appendYAMLMappingValue(node, name, value); err != nil {
return nil, err
}
node.Content = append(node.Content, keyNode, valueNode)
continue
}
category, ok := briefingStanzaCategories[name]
if !ok {
return nil, fmt.Errorf("briefing stanza %q has no prompt-input category", name)
}
categoryNames[category] = append(categoryNames[category], name)
}
for _, category := range briefingCategoryOrder {
names := categoryNames[category]
if len(names) == 0 {
continue
}
categoryNode := &yaml.Node{Kind: yaml.MappingNode}
for _, name := range names {
value := b.Values[name]
if err := appendYAMLMappingValue(categoryNode, name, value); err != nil {
return nil, err
}
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: category},
categoryNode,
)
}
return node, nil
}
@@ -212,14 +275,46 @@ func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
}
values := map[string]any{}
order := make([]string, 0, len(value.Content)/2)
seen := map[string]struct{}{}
seenCategories := map[string]struct{}{}
categoryOrder := map[string][]string{}
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 {
if name == metadataStanza {
if err := decodeBriefingStanza(value.Content[i+1], name, values, &order, seen); err != nil {
return err
}
order = append(order, name)
values[name] = stanza
continue
}
if !knownBriefingCategory(name) {
return fmt.Errorf("unknown briefing category %q", name)
}
if _, ok := seenCategories[name]; ok {
return fmt.Errorf("duplicate briefing category %q", name)
}
seenCategories[name] = struct{}{}
categoryNode := value.Content[i+1]
if categoryNode.Kind != yaml.MappingNode {
return fmt.Errorf("briefing category %q must be a mapping", name)
}
var names []string
for j := 0; j < len(categoryNode.Content); j += 2 {
stanzaName := categoryNode.Content[j].Value
category, ok := briefingStanzaCategories[stanzaName]
if !ok {
return fmt.Errorf("briefing stanza %q has no prompt-input category", stanzaName)
}
if category != name {
return fmt.Errorf("briefing stanza %q belongs under category %q, not %q", stanzaName, category, name)
}
if err := decodeBriefingStanza(categoryNode.Content[j+1], stanzaName, values, &names, seen); err != nil {
return err
}
}
categoryOrder[name] = names
}
for _, category := range briefingCategoryOrder {
order = append(order, categoryOrder[category]...)
}
b.Order = order
b.Values = values
@@ -250,6 +345,39 @@ func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
return nil
}
func appendYAMLMappingValue(node *yaml.Node, name string, value any) error {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
valueNode, err := yamlNode(value)
if err != nil {
return fmt.Errorf("marshal briefing stanza %q: %w", name, err)
}
node.Content = append(node.Content, keyNode, valueNode)
return nil
}
func decodeBriefingStanza(node *yaml.Node, name string, values map[string]any, order *[]string, seen map[string]struct{}) error {
if _, ok := seen[name]; ok {
return fmt.Errorf("duplicate briefing stanza %q", name)
}
var stanza any
if err := node.Decode(&stanza); err != nil {
return err
}
seen[name] = struct{}{}
*order = append(*order, name)
values[name] = stanza
return nil
}
func knownBriefingCategory(name string) bool {
for _, category := range briefingCategoryOrder {
if name == category {
return true
}
}
return false
}
func yamlNode(value any) (*yaml.Node, error) {
data, err := json.Marshal(value)
if err != nil {

View File

@@ -106,7 +106,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
req.Metadata.PromptID = "weather.three_day_outlook"
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.ForecastDelta, StanzaName: "three_day", Value: map[string]any{"days": []string{"2026-05-29"}}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
)
pkg, err := Build(req)
@@ -117,12 +117,12 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
if pkg.Report.ID != report.ThreeDay {
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
}
if _, ok := pkg.Briefing.Values["three_day"]; !ok {
t.Fatal("Briefing.Values[three_day] missing")
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
}
}
func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
@@ -141,9 +141,26 @@ func TestMarshalYAMLIsDeterministicAndUsesNamedStanzas(t *testing.T) {
}
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(first), "briefing:\n") ||
!strings.Contains(string(first), " applicable_risk_products:\n") ||
!strings.Contains(string(first), " derived_summaries:\n") ||
!strings.Contains(string(first), " narrative_products:\n") ||
!strings.Contains(string(first), " raw_data:\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))
t.Fatalf("YAML output missing expected grouped stanzas:\n%s", string(first))
}
for _, pair := range []struct {
before string
after string
}{
{before: " metadata:\n", after: " applicable_risk_products:\n"},
{before: " applicable_risk_products:\n", after: " derived_summaries:\n"},
{before: " derived_summaries:\n", after: " narrative_products:\n"},
{before: " narrative_products:\n", after: " raw_data:\n"},
} {
if strings.Index(string(first), pair.before) < 0 || strings.Index(string(first), pair.after) < 0 || strings.Index(string(first), pair.before) > strings.Index(string(first), pair.after) {
t.Fatalf("YAML category order is wrong, want %q before %q:\n%s", pair.before, pair.after, string(first))
}
}
}
@@ -165,8 +182,51 @@ func TestLoadYAMLRoundTrip(t *testing.T) {
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)
wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"}
if strings.Join(loaded.Briefing.Order, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("loaded package order = %#v, want grouped category order %#v", loaded.Briefing.Order, wantOrder)
}
if got := loaded.Briefing.Values["current_conditions"].(map[string]any)["condition_text"]; got != "Partly cloudy" {
t.Fatalf("loaded current_conditions.condition_text = %#v, want Partly cloudy", got)
}
}
func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t, module.Output{ID: module.ID("custom"), StanzaName: "custom", Value: map[string]string{"value": "x"}})
_, err := Build(req)
if err == nil || !strings.Contains(err.Error(), `briefing stanza "custom" has no prompt-input category`) {
t.Fatalf("Build() error = %v, want uncategorized stanza error", err)
}
}
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
data := []byte(`
schema_version: weatherreporter.data_package.v2
run_id: 20260529T100000Z_daily_today
report:
id: daily_today
prompt_id: weather.daily_report
generated_at: 2026-05-29T10:00:00Z
timezone: America/Chicago
current_local_date: "2026-05-29"
valid_period:
start: 2026-05-29T05:00:00Z
end: 2026-05-30T05:00:00Z
briefing:
metadata:
run_id: 20260529T100000Z_daily_today
raw_data:
alert_digest:
checked: true
recent_changes:
items: []
`)
_, err := LoadYAML(data)
if err == nil || !strings.Contains(err.Error(), `briefing stanza "alert_digest" belongs under category "applicable_risk_products"`) {
t.Fatalf("LoadYAML() error = %v, want misplaced stanza error", err)
}
}
@@ -190,6 +250,8 @@ func validBuildRequest(t *testing.T) BuildRequest {
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"}},
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: map[string]string{"product": "narrative"}},
),
}
}

View File

@@ -0,0 +1,82 @@
package report
import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func dailyTodayDefinition() Definition {
return Definition{
ID: DailyToday,
Name: "Daily Report",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTodayModules(),
Morning: true,
resolve: resolveDailyToday,
}
}
func dailyTomorrowDefinition() Definition {
return Definition{
ID: DailyTomorrow,
Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTomorrowModules(),
Evening: true,
resolve: resolveDailyTomorrow,
}
}
func dailyTodayModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
)
}
func dailyTomorrowModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
)
}
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil
}
return timeutil.CivilDay(req.Now, req.Location), nil
}
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
}

View File

@@ -3,8 +3,6 @@ package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
@@ -70,75 +68,3 @@ func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (
ValidPeriod: period,
}, nil
}
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil
}
return timeutil.CivilDay(req.Now, req.Location), nil
}
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
if location == nil {
location = time.UTC
}
startTime, err := timeutil.ParseStormTime(start, location)
if err != nil {
return timeutil.Period{}, err
}
endTime, err := timeutil.ParseStormTime(end, location)
if err != nil {
return timeutil.Period{}, err
}
period := timeutil.Period{Start: startTime, End: endTime}
if !period.IsValid() {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return period, nil
}
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
}
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
endDate := localNow.AddDate(0, 0, 3)
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
return timeutil.Period{Start: localNow, End: end}, nil
}
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
weekday := localNow.Weekday()
if weekday == time.Sunday {
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
}
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
if weekday == time.Friday || weekday == time.Saturday {
friday := start.AddDate(0, 0, -1)
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
start = fridayEvening
if localNow.After(start) {
start = localNow
}
}
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
return timeutil.Period{Start: start, End: end}, nil
}
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
if req.StormStart.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
}
if req.StormEnd.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
}
if !req.StormEnd.After(req.StormStart) {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
}

View File

@@ -262,14 +262,15 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
},
},
{
@@ -277,15 +278,16 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
},
},
{
@@ -296,7 +298,6 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.ForecastDelta,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
@@ -313,7 +314,6 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.WeekendPlanning,
},
},
{
@@ -321,12 +321,10 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyTable,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.StormWindowSummary,
},
},
}

View File

@@ -12,70 +12,11 @@ type Registry struct {
func DefaultRegistry() Registry {
definitions := []Definition{
{
ID: DailyToday,
Name: "Daily Report",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTodayModules(),
Morning: true,
resolve: resolveDailyToday,
},
{
ID: DailyTomorrow,
Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTomorrowModules(),
Evening: true,
resolve: resolveDailyTomorrow,
},
{
ID: ThreeDay,
Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
Generated: true,
CompatiblePriorIDs: []ID{ThreeDay},
Modules: threeDayModules(),
Morning: true,
resolve: resolveThreeDay,
},
{
ID: Weekend,
Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow,
ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
Generated: true,
CompatiblePriorIDs: []ID{Weekend},
Modules: weekendModules(),
Morning: true,
resolve: resolveWeekend,
},
{
ID: Storm,
Name: "Storm Report",
PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow,
ArtifactGroup: "storm",
BatchOutputName: "storm.md",
Generated: true,
CompatiblePriorIDs: []ID{Storm},
Modules: stormModules(),
resolve: resolveStorm,
},
dailyTodayDefinition(),
dailyTomorrowDefinition(),
threeDayDefinition(),
weekendDefinition(),
stormDefinition(),
}
registry := Registry{definitions: map[ID]Definition{}}
for _, definition := range definitions {
@@ -101,68 +42,6 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg
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 {

View File

@@ -0,0 +1,67 @@
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func stormDefinition() Definition {
return Definition{
ID: Storm,
Name: "Storm Report",
PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow,
ArtifactGroup: "storm",
BatchOutputName: "storm.md",
Generated: true,
CompatiblePriorIDs: []ID{Storm},
Modules: stormModules(),
resolve: resolveStorm,
}
}
func stormModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
)
}
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
if location == nil {
location = time.UTC
}
startTime, err := timeutil.ParseStormTime(start, location)
if err != nil {
return timeutil.Period{}, err
}
endTime, err := timeutil.ParseStormTime(end, location)
if err != nil {
return timeutil.Period{}, err
}
period := timeutil.Period{Start: startTime, End: endTime}
if !period.IsValid() {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return period, nil
}
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
if req.StormStart.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
}
if req.StormEnd.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
}
if !req.StormEnd.After(req.StormStart) {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
}

View File

@@ -0,0 +1,44 @@
package report
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func threeDayDefinition() Definition {
return Definition{
ID: ThreeDay,
Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
Generated: true,
CompatiblePriorIDs: []ID{ThreeDay},
Modules: threeDayModules(),
Morning: true,
resolve: resolveThreeDay,
}
}
func threeDayModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)
}
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
endDate := localNow.AddDate(0, 0, 3)
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
return timeutil.Period{Start: localNow, End: end}, nil
}

View File

@@ -0,0 +1,60 @@
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func weekendDefinition() Definition {
return Definition{
ID: Weekend,
Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow,
ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
Generated: true,
CompatiblePriorIDs: []ID{Weekend},
Modules: weekendModules(),
Morning: true,
resolve: resolveWeekend,
}
}
func weekendModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)
}
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
weekday := localNow.Weekday()
if weekday == time.Sunday {
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
}
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
if weekday == time.Friday || weekday == time.Saturday {
friday := start.AddDate(0, 0, -1)
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
start = fridayEvening
if localNow.After(start) {
start = localNow
}
}
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
return timeutil.Period{Start: start, End: end}, nil
}