Remove obsolete briefing snapshot artifacts
This commit is contained in:
@@ -34,7 +34,7 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
|||||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate` commands write briefing, module snapshot, data package, preflight,
|
`generate` commands write module snapshot, data package, preflight,
|
||||||
report, and metadata artifacts under the configured workspace. `--out` writes
|
report, and metadata artifacts under the configured workspace. `--out` writes
|
||||||
an extra Markdown copy for the operator; distributor notification uses the
|
an extra Markdown copy for the operator; distributor notification uses the
|
||||||
managed report path, not the extra copy. `generate storm` requires explicit
|
managed report path, not the extra copy. `generate storm` requires explicit
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes the implemented workflow coordinator in `internal/app`.
|
|||||||
|
|
||||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||||
loading are complete. It resolves report definitions, fetches weather data,
|
loading are complete. It resolves report definitions, fetches weather data,
|
||||||
builds collected and derived facts, builds briefing and prompt-input artifacts,
|
builds collected and derived facts, builds module snapshots and prompt-input artifacts,
|
||||||
invokes Scriptorium through the adapter boundary, optionally notifies
|
invokes Scriptorium through the adapter boundary, optionally notifies
|
||||||
distributor through an app-owned notifier boundary, persists managed state, runs
|
distributor through an app-owned notifier boundary, persists managed state, runs
|
||||||
batches, and reads existing artifacts for inspection.
|
batches, and reads existing artifacts for inspection.
|
||||||
@@ -18,8 +18,7 @@ Inputs:
|
|||||||
- `GenerateRequest` for one report command
|
- `GenerateRequest` for one report command
|
||||||
- `BatchRequest` for morning or evening batch commands
|
- `BatchRequest` for morning or evening batch commands
|
||||||
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
||||||
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
|
- `ReportRequest` for single-report generation
|
||||||
and internal composition
|
|
||||||
- resolved report definitions from `internal/report`
|
- resolved report definitions from `internal/report`
|
||||||
- weather data bundles from `internal/adapters/weatherapi`
|
- weather data bundles from `internal/adapters/weatherapi`
|
||||||
- prior snapshots loaded from `internal/state`
|
- prior snapshots loaded from `internal/state`
|
||||||
@@ -27,13 +26,13 @@ Inputs:
|
|||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- generated report results with briefing, data package, preflight, report,
|
- generated report results with module snapshot, data package, preflight, report,
|
||||||
metadata, prior snapshot, Recent Changes, Scriptorium result details, and
|
metadata, prior snapshot, Recent Changes, Scriptorium result details, and
|
||||||
notification result when attempted
|
notification result when attempted
|
||||||
- batch summaries with per-report status, artifact paths, error text, and
|
- batch summaries with per-report status, artifact paths, error text, and
|
||||||
notification outcome when attempted
|
notification outcome when attempted
|
||||||
- saved Weather API bundle JSON for fetch workflows
|
- saved Weather API bundle JSON for fetch workflows
|
||||||
- inspection JSON values for reports, metadata, briefings, data packages, prior
|
- inspection JSON values for reports, metadata, module snapshots, data packages, prior
|
||||||
snapshots, and source provenance
|
snapshots, and source provenance
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -72,25 +71,24 @@ Single-report generation follows this order:
|
|||||||
4. Fetch a Weather API bundle.
|
4. Fetch a Weather API bundle.
|
||||||
5. Build collected and derived facts once.
|
5. Build collected and derived facts once.
|
||||||
6. Execute configured modules and save the module snapshot.
|
6. Execute configured modules and save the module snapshot.
|
||||||
7. Build and save the compatibility briefing snapshot.
|
7. Compute Recent Changes from structured prior and current module snapshots.
|
||||||
8. Compute Recent Changes from structured prior and current module snapshots.
|
8. Build and save the YAML Scriptorium `data_package`.
|
||||||
9. Build and save the YAML Scriptorium `data_package`.
|
9. Run Scriptorium render preflight.
|
||||||
10. Run Scriptorium render preflight.
|
10. Save preflight JSON when a render result is available.
|
||||||
11. Save preflight JSON when a render result is available.
|
11. Save metadata for inspection.
|
||||||
12. Save metadata for inspection.
|
12. Run Scriptorium report generation to the managed report path.
|
||||||
13. Run Scriptorium report generation to the managed report path.
|
13. Copy the managed report to the requested `--out` path when provided.
|
||||||
14. Copy the managed report to the requested `--out` path when provided.
|
14. Save metadata with the managed report path.
|
||||||
15. Save metadata with the managed report path.
|
15. If distributor notification is enabled, notify using the managed report
|
||||||
16. If distributor notification is enabled, notify using the managed report
|
|
||||||
path as the source file.
|
path as the source file.
|
||||||
17. Save a distributor notification debug artifact and update metadata with its
|
16. Save a distributor notification debug artifact and update metadata with its
|
||||||
path.
|
path.
|
||||||
|
|
||||||
If render preflight returns both a result and an error, preflight JSON and
|
If render preflight returns both a result and an error, preflight JSON and
|
||||||
metadata are persisted before the error is returned. If Scriptorium report
|
metadata are persisted before the error is returned. If Scriptorium report
|
||||||
generation returns an error after writing output, the managed report and
|
generation returns an error after writing output, the managed report and
|
||||||
metadata remain inspectable. Notification is not attempted after Weather API,
|
metadata remain inspectable. Notification is not attempted after Weather API,
|
||||||
briefing, prompt input, render, Scriptorium run, or metadata-save failures.
|
module snapshot, prompt input, render, Scriptorium run, or metadata-save failures.
|
||||||
When notification is attempted, the debug artifact records request identity,
|
When notification is attempted, the debug artifact records request identity,
|
||||||
including rendered pipeline ID, bundle paths, accepted upload fields,
|
including rendered pipeline ID, bundle paths, accepted upload fields,
|
||||||
distributor status fields, raw status report JSON when available, and redacted
|
distributor status fields, raw status report JSON when available, and redacted
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ This document describes the implemented briefing package boundary.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/briefing` builds structured report-specific briefing packages from
|
`internal/briefing` builds prompt-facing module values from resolved report
|
||||||
resolved report metadata, collected weather data, and derived forecast facts.
|
metadata, collected weather data, and derived forecast facts. These values are
|
||||||
Briefings are curated inputs for prompt data packages, not rendered report prose.
|
curated prompt inputs, not rendered report prose or durable report snapshots.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -19,23 +19,19 @@ Inputs:
|
|||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `briefing.Package` with common metadata and one report-specific content
|
|
||||||
object for Daily, 3-Day, Weekend, or Storm Report
|
|
||||||
- module registry definitions for known module IDs, stanza names, option
|
- module registry definitions for known module IDs, stanza names, option
|
||||||
shapes, fact requirements, report compatibility, and missing-data behavior
|
shapes, fact requirements, report compatibility, and missing-data behavior
|
||||||
- source-oriented module outputs for `metadata`, `current_conditions`,
|
- source-oriented module outputs for `metadata`, `current_conditions`,
|
||||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||||
- optional `currentConditions` prompt context from normalized
|
- derived module outputs such as daily summaries, daypart summaries,
|
||||||
`/conditions/current` data when available
|
precipitation timing, outdoor windows, and tomorrow planning
|
||||||
- optional structured `weatherStory` context on report-specific briefing
|
- optional current conditions and weather story module outputs when those
|
||||||
objects when `/weatherstories/latest` is available
|
Weather API sources are available
|
||||||
- optional JSON file written by `briefing.Save`
|
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package selects and shapes weather facts for prompts.
|
- This package selects and shapes weather facts for prompts.
|
||||||
- It owns module registry validation, but app orchestration does not execute
|
- It owns module registry validation and module builder behavior.
|
||||||
modules yet.
|
|
||||||
- It does not fetch weather data, compare prior snapshots, build
|
- It does not fetch weather data, compare prior snapshots, build
|
||||||
`data_package` files, invoke Scriptorium, or write workflow metadata.
|
`data_package` files, invoke Scriptorium, or write workflow metadata.
|
||||||
|
|
||||||
@@ -54,23 +50,18 @@ None directly.
|
|||||||
|
|
||||||
## State Or Manifest Behavior
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
|
None. Module snapshots and prompt input data packages are persisted by
|
||||||
owned by `internal/state`.
|
`internal/state` and composed by `internal/app`.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
None. Builders either return a complete briefing package or an error.
|
None. Builders either return a module output, omit optional unavailable data,
|
||||||
|
or return an error for invalid required inputs.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Daily briefing construction requires a Daily report definition and derived
|
- Required derived modules return errors when their dependent facts are not
|
||||||
daily summary.
|
available.
|
||||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
|
||||||
one derived summary.
|
|
||||||
- Weekend briefing construction requires a Weekend report definition and at
|
|
||||||
least one derived summary.
|
|
||||||
- Storm briefing construction requires a Storm Report definition and forecast
|
|
||||||
bundle.
|
|
||||||
- Module registry construction rejects duplicate module IDs and duplicate
|
- Module registry construction rejects duplicate module IDs and duplicate
|
||||||
stanza names.
|
stanza names.
|
||||||
- Module composition validation rejects unknown modules, duplicate modules,
|
- Module composition validation rejects unknown modules, duplicate modules,
|
||||||
@@ -79,23 +70,19 @@ None. Builders either return a complete briefing package or an error.
|
|||||||
forecast discussion, and weather story stanzas.
|
forecast discussion, and weather story stanzas.
|
||||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||||
source data.
|
source data.
|
||||||
- Save failures include path and operation context.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/briefing/daily_test.go`
|
|
||||||
- `internal/briefing/three_day_test.go`
|
|
||||||
- `internal/briefing/weekend_test.go`
|
|
||||||
- `internal/briefing/storm_test.go`
|
|
||||||
- `internal/briefing/base_modules_test.go`
|
- `internal/briefing/base_modules_test.go`
|
||||||
|
- `internal/briefing/derived_modules_test.go`
|
||||||
- `internal/briefing/modules_test.go`
|
- `internal/briefing/modules_test.go`
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Briefings contain structured weather facts and source context.
|
- Module outputs contain structured weather facts and source context.
|
||||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||||
provenance, source hashes, source warnings, and configured prompt location.
|
provenance, source hashes, source warnings, and configured prompt location.
|
||||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ membership, output naming, artifact grouping, and comparison declarations in
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/report` is the canonical source for report definitions. App, state,
|
`internal/report` is the canonical source for report definitions. App, state,
|
||||||
briefing, and CLI wiring consume resolved definitions instead of owning report
|
module building, and CLI wiring consume resolved definitions instead of owning report
|
||||||
identity policy themselves.
|
identity policy themselves.
|
||||||
|
|
||||||
## Definition Fields
|
## Definition Fields
|
||||||
@@ -55,7 +55,7 @@ must be after start time.
|
|||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||||
weather data, build briefings, compare briefing contents, write state, parse CLI
|
weather data, build module values, compare snapshot contents, write state, parse CLI
|
||||||
flags, or invoke Scriptorium.
|
flags, or invoke Scriptorium.
|
||||||
|
|
||||||
The CLI owns public command names. The app maps those command names to report
|
The CLI owns public command names. The app maps those command names to report
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Inputs:
|
|||||||
|
|
||||||
- workspace configuration
|
- workspace configuration
|
||||||
- resolved report definition and valid period
|
- resolved report definition and valid period
|
||||||
- briefing package
|
- module snapshot
|
||||||
- prompt input data package
|
- prompt input data package
|
||||||
- preflight artifact
|
- preflight artifact
|
||||||
- rendered report path preparation request
|
- rendered report path preparation request
|
||||||
@@ -21,20 +21,20 @@ Inputs:
|
|||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- briefing snapshot JSON path
|
- module snapshot JSON path
|
||||||
- prompt input data package JSON path
|
- prompt input data package YAML path
|
||||||
- render preflight JSON path
|
- render preflight JSON path
|
||||||
- managed Markdown report path
|
- managed Markdown report path
|
||||||
- metadata JSON path
|
- metadata JSON path
|
||||||
- prior comparable snapshot metadata
|
- prior comparable snapshot metadata
|
||||||
- loaded briefing or data package
|
- loaded module snapshot or data package
|
||||||
- recent report records for inspection
|
- recent report records for inspection
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||||
data, derive forecasts, build prompt input content, compare briefing contents,
|
data, derive forecasts, build prompt input content, compare module contents,
|
||||||
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
||||||
|
|
||||||
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
||||||
@@ -59,7 +59,7 @@ valid-period start date for dated artifacts, and the RunID.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
<workspace.root>/
|
<workspace.root>/
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||||
@@ -67,7 +67,7 @@ valid-period start date for dated artifacts, and the RunID.
|
|||||||
reports/<artifact_group>/<run_id>.md
|
reports/<artifact_group>/<run_id>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Metadata is stored beside briefing snapshots and links the briefing, data
|
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||||
package, preflight, report paths, notification path when attempted, and
|
package, preflight, report paths, notification path when attempted, and
|
||||||
configured prompt location. Report listing walks metadata files under the
|
configured prompt location. Report listing walks metadata files under the
|
||||||
snapshots directory.
|
snapshots directory.
|
||||||
@@ -97,7 +97,7 @@ notification is attempted and include rendered distributor pipeline ID, bundle
|
|||||||
ID, idempotency key, bundle paths, upload status, latest run status, and
|
ID, idempotency key, bundle paths, upload status, latest run status, and
|
||||||
redacted errors.
|
redacted errors.
|
||||||
|
|
||||||
Inspection helpers read existing metadata, briefing, and data package files.
|
Inspection helpers read existing metadata, module snapshot, and data package files.
|
||||||
Missing metadata directories return no inspection records or no prior snapshot
|
Missing metadata directories return no inspection records or no prior snapshot
|
||||||
rather than creating state.
|
rather than creating state.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
|
|||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||||
derivation and briefing builders. Briefing builders expose normalized current
|
derivation and module builders. Module builders expose normalized current
|
||||||
conditions and weather story context when those sources are available.
|
conditions and weather story context when those sources are available.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
@@ -29,7 +29,7 @@ Outputs:
|
|||||||
|
|
||||||
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
||||||
decoding into internal bundle types.
|
decoding into internal bundle types.
|
||||||
- It does not derive dayparts, resolve report periods, build briefings, compare
|
- It does not derive dayparts, resolve report periods, build module values, compare
|
||||||
snapshots, write report state, or invoke Scriptorium.
|
snapshots, write report state, or invoke Scriptorium.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|||||||
@@ -52,22 +52,18 @@ workspace/
|
|||||||
snapshots/
|
snapshots/
|
||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
data-packages/
|
data-packages/
|
||||||
@@ -136,7 +132,7 @@ Each generated report writes metadata that links:
|
|||||||
- RunID, report ID, variant, and prompt ID
|
- RunID, report ID, variant, and prompt ID
|
||||||
- generation time, timezone, and valid period
|
- generation time, timezone, and valid period
|
||||||
- source location, source hashes, and source warnings
|
- source location, source hashes, and source warnings
|
||||||
- briefing snapshot path
|
- module snapshot path
|
||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- preflight output path
|
- preflight output path
|
||||||
- managed Markdown report path
|
- managed Markdown report path
|
||||||
@@ -169,9 +165,9 @@ report generation has a distinct retry identity. The default bundle path uses
|
|||||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||||
|
|
||||||
Notification happens after final metadata save. Weather API, briefing, module
|
Notification happens after final metadata save. Weather API, module snapshot,
|
||||||
snapshot, data-package, render preflight, Scriptorium run, and metadata-save
|
data-package, render preflight, Scriptorium run, and metadata-save failures do
|
||||||
failures do not trigger notification. A notification failure fails that report.
|
not trigger notification. A notification failure fails that report.
|
||||||
In a batch, other reports continue, the failed report includes notification
|
In a batch, other reports continue, the failed report includes notification
|
||||||
fields in the JSON summary, and the batch returns nonzero.
|
fields in the JSON summary, and the batch returns nonzero.
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ This document defines the development principles for this Go project. It is inwa
|
|||||||
## weatherreporter
|
## weatherreporter
|
||||||
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
|
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
|
||||||
|
|
||||||
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical.
|
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated module-based prompt packages rather than raw unbounded source payloads wherever practical.
|
||||||
|
|
||||||
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, briefing builder, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, module composition, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
||||||
|
|
||||||
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, briefing snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
||||||
|
|
||||||
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Developers and LLM coding agents should use it with
|
|||||||
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
|
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
|
||||||
- `internal/report`: report definitions, valid periods, batches, output names,
|
- `internal/report`: report definitions, valid periods, batches, output names,
|
||||||
and comparison declarations.
|
and comparison declarations.
|
||||||
- `internal/briefing`: report-specific briefing package builders.
|
- `internal/briefing`: prompt-facing module value builders and module registry.
|
||||||
- `internal/changes`: structured Recent Changes comparison.
|
- `internal/changes`: structured Recent Changes comparison.
|
||||||
- `internal/promptinput`: Scriptorium `data_package` construction and
|
- `internal/promptinput`: Scriptorium `data_package` construction and
|
||||||
validation.
|
validation.
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ Diagnostic: inspect stdout for the JSON summary and stderr for compact status
|
|||||||
lines.
|
lines.
|
||||||
|
|
||||||
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
||||||
metadata, sources, briefing, and data package for that RunID.
|
metadata, sources, module snapshot, and data package for that RunID.
|
||||||
|
|
||||||
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,6 @@ type FetchBundleRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BriefingRequest struct {
|
|
||||||
Config config.Config
|
|
||||||
Resolved report.Resolved
|
|
||||||
OutputPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModuleSnapshotRequest struct {
|
type ModuleSnapshotRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
@@ -93,15 +87,8 @@ type ReportRequest struct {
|
|||||||
Notifier Notifier
|
Notifier Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
type BriefingResult struct {
|
|
||||||
Package briefing.Package
|
|
||||||
OutputPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReportResult struct {
|
type ReportResult struct {
|
||||||
Briefing briefing.Package
|
|
||||||
ModuleSnapshot module.Snapshot
|
ModuleSnapshot module.Snapshot
|
||||||
BriefingPath string
|
|
||||||
ModuleSnapshotPath string
|
ModuleSnapshotPath string
|
||||||
DataPackage promptinput.Package
|
DataPackage promptinput.Package
|
||||||
DataPackagePath string
|
DataPackagePath string
|
||||||
@@ -142,7 +129,6 @@ type BatchReportResult struct {
|
|||||||
NotificationPath string `json:"notificationPath,omitempty"`
|
NotificationPath string `json:"notificationPath,omitempty"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
BriefingPath string `json:"briefingPath,omitempty"`
|
|
||||||
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||||
PreflightPath string `json:"preflightPath,omitempty"`
|
PreflightPath string `json:"preflightPath,omitempty"`
|
||||||
ReportPath string `json:"reportPath,omitempty"`
|
ReportPath string `json:"reportPath,omitempty"`
|
||||||
@@ -275,7 +261,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
for _, resolved := range resolvedReports {
|
for _, resolved := range resolvedReports {
|
||||||
item := batchReportResult(resolved)
|
item := batchReportResult(resolved)
|
||||||
if paths, err := store.Paths(resolved); err == nil {
|
if paths, err := store.Paths(resolved); err == nil {
|
||||||
item.BriefingPath = paths.Briefing
|
|
||||||
item.DataPackagePath = paths.DataPackage
|
item.DataPackagePath = paths.DataPackage
|
||||||
item.PreflightPath = paths.Preflight
|
item.PreflightPath = paths.Preflight
|
||||||
item.ReportPath = paths.RenderedReport
|
item.ReportPath = paths.RenderedReport
|
||||||
@@ -305,7 +290,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
result.Failed++
|
result.Failed++
|
||||||
} else {
|
} else {
|
||||||
item.Status = "succeeded"
|
item.Status = "succeeded"
|
||||||
item.BriefingPath = reportResult.BriefingPath
|
|
||||||
item.DataPackagePath = reportResult.DataPackagePath
|
item.DataPackagePath = reportResult.DataPackagePath
|
||||||
item.PreflightPath = reportResult.PreflightPath
|
item.PreflightPath = reportResult.PreflightPath
|
||||||
item.ReportPath = reportResult.ReportPath
|
item.ReportPath = reportResult.ReportPath
|
||||||
@@ -450,33 +434,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
|
|||||||
return bundle, nil
|
return bundle, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
|
|
||||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pkg, err := BuildBriefing(req, bundle)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
outputPath := req.OutputPath
|
|
||||||
if outputPath == "" {
|
|
||||||
store, err := defaultStore(req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths, err := store.Paths(req.Resolved)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
outputPath = paths.Briefing
|
|
||||||
}
|
|
||||||
if err := briefing.Save(outputPath, pkg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||||
store := req.Store
|
store := req.Store
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -518,17 +475,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
briefingPackage, err := BuildBriefingFromFacts(BriefingRequest{
|
|
||||||
Config: req.Config,
|
|
||||||
Resolved: req.Resolved,
|
|
||||||
}, reportFacts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
|
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -536,7 +482,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
}
|
}
|
||||||
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||||
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: moduleSnapshotPath,
|
ModuleSnapshot: moduleSnapshotPath,
|
||||||
Metadata: paths.Metadata,
|
Metadata: paths.Metadata,
|
||||||
DataPackage: paths.DataPackage,
|
DataPackage: paths.DataPackage,
|
||||||
@@ -629,9 +574,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &ReportResult{
|
return &ReportResult{
|
||||||
Briefing: briefingPackage,
|
|
||||||
ModuleSnapshot: moduleSnapshot,
|
ModuleSnapshot: moduleSnapshot,
|
||||||
BriefingPath: briefingPath,
|
|
||||||
ModuleSnapshotPath: moduleSnapshotPath,
|
ModuleSnapshotPath: moduleSnapshotPath,
|
||||||
DataPackage: dataPackage,
|
DataPackage: dataPackage,
|
||||||
DataPackagePath: dataPackagePath,
|
DataPackagePath: dataPackagePath,
|
||||||
@@ -846,34 +789,6 @@ func distributorUploadFiles(sourcePath string, bundlePaths []string) []distribut
|
|||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildBriefing(req BriefingRequest, bundle *weatherdata.Bundle) (briefing.Package, error) {
|
|
||||||
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
|
|
||||||
Config: req.Config,
|
|
||||||
Resolved: req.Resolved,
|
|
||||||
}, bundle)
|
|
||||||
if err != nil {
|
|
||||||
return briefing.Package{}, err
|
|
||||||
}
|
|
||||||
return BuildBriefingFromFacts(req, reportFacts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildBriefingFromFacts(req BriefingRequest, reportFacts ReportFacts) (briefing.Package, error) {
|
|
||||||
buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)
|
|
||||||
switch req.Resolved.Definition.ID {
|
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
|
||||||
return briefing.BuildDaily(buildContext, reportFacts.Derived.FirstDailySummary())
|
|
||||||
case report.ThreeDay, report.Weekend:
|
|
||||||
if req.Resolved.Definition.ID == report.Weekend {
|
|
||||||
return briefing.BuildWeekend(buildContext, reportFacts.Derived.DailySummaries)
|
|
||||||
}
|
|
||||||
return briefing.BuildThreeDay(buildContext, reportFacts.Derived.DailySummaries)
|
|
||||||
case report.Storm:
|
|
||||||
return briefing.BuildStorm(buildContext, reportFacts.Derived)
|
|
||||||
default:
|
|
||||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildModuleSnapshot(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (module.Snapshot, error) {
|
func BuildModuleSnapshot(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (module.Snapshot, error) {
|
||||||
reportFacts, err := BuildReportFacts(req, bundle)
|
reportFacts, err := BuildReportFacts(req, bundle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -79,67 +78,6 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateBriefingWritesArtifact(t *testing.T) {
|
|
||||||
server := dailyBundleServer(t)
|
|
||||||
cfg := dailyTestConfig(t, server)
|
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
|
||||||
Config: cfg,
|
|
||||||
Report: ReportDaily,
|
|
||||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
||||||
}
|
|
||||||
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
|
||||||
|
|
||||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
|
||||||
Config: cfg,
|
|
||||||
Resolved: resolved,
|
|
||||||
OutputPath: path,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
if result.OutputPath != path {
|
|
||||||
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
|
||||||
}
|
|
||||||
if result.Package.Daily == nil {
|
|
||||||
t.Fatal("Daily = nil")
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read briefing artifact: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily"`) {
|
|
||||||
t.Fatalf("briefing artifact missing expected fields:\n%s", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateBriefingDefaultPath(t *testing.T) {
|
|
||||||
server := dailyBundleServer(t)
|
|
||||||
cfg := dailyTestConfig(t, server)
|
|
||||||
cfg.Workspace.Root = t.TempDir()
|
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
|
||||||
Config: cfg,
|
|
||||||
Report: ReportDaily,
|
|
||||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
|
||||||
Config: cfg,
|
|
||||||
Resolved: resolved,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
|
|
||||||
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := dailyTestConfig(t, server)
|
cfg := dailyTestConfig(t, server)
|
||||||
@@ -202,10 +140,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if renderer.runRequest.OutputPath != result.ReportPath {
|
if renderer.runRequest.OutputPath != result.ReportPath {
|
||||||
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
||||||
}
|
}
|
||||||
if got, want := strings.Join(store.calls, ","), "module_snapshot,briefing,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
|
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
|
||||||
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
|
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
|
||||||
}
|
}
|
||||||
assertPathsExist(t, result.BriefingPath, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||||
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
|
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read module snapshot: %v", err)
|
t.Fatalf("read module snapshot: %v", err)
|
||||||
@@ -257,7 +195,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if result.Metadata.RunID != resolved.Metadata().RunID {
|
if result.Metadata.RunID != resolved.Metadata().RunID {
|
||||||
t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID)
|
t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID)
|
||||||
}
|
}
|
||||||
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
if result.Metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
||||||
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
|
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
|
||||||
}
|
}
|
||||||
if result.Metadata.RenderedReportPath != result.ReportPath {
|
if result.Metadata.RenderedReportPath != result.ReportPath {
|
||||||
@@ -655,8 +593,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
}
|
}
|
||||||
priorBriefing := priorDailyBriefing(priorResolved)
|
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
||||||
savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved))
|
|
||||||
|
|
||||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -721,14 +658,18 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|||||||
t.Fatalf("GenerateReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
if result.Metadata.ReportID != report.DailyTomorrow || result.Metadata.Variant != "tomorrow" {
|
||||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant)
|
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
|
||||||
}
|
}
|
||||||
if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" {
|
dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
|
||||||
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate)
|
if err != nil {
|
||||||
|
t.Fatalf("decode daily summary: %v", err)
|
||||||
}
|
}
|
||||||
if result.Briefing.Daily.Planning == nil {
|
if !ok || dailySummary["date"] != "2026-05-30" {
|
||||||
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
|
||||||
|
}
|
||||||
|
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
||||||
|
t.Fatal("tomorrow_planning stanza missing")
|
||||||
}
|
}
|
||||||
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") {
|
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") {
|
||||||
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
|
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
|
||||||
@@ -753,8 +694,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
}
|
}
|
||||||
priorBriefing := priorDailyBriefing(priorResolved)
|
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
||||||
savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved))
|
|
||||||
|
|
||||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -803,8 +743,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
}
|
}
|
||||||
priorBriefing := priorThreeDayBriefing(priorResolved)
|
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
||||||
savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
|
||||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: ReportThreeDay,
|
Report: ReportThreeDay,
|
||||||
@@ -828,11 +767,12 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
t.Fatalf("GenerateReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Briefing.ThreeDay == nil {
|
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
|
||||||
t.Fatal("ThreeDay = nil")
|
if err != nil {
|
||||||
|
t.Fatalf("decode daypart summaries: %v", err)
|
||||||
}
|
}
|
||||||
if len(result.Briefing.ThreeDay.Days) != 3 {
|
if !ok || len(dayparts) == 0 {
|
||||||
t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days))
|
t.Fatalf("daypart summaries = %#v, want 3-day module content", dayparts)
|
||||||
}
|
}
|
||||||
if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
|
if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
|
||||||
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
|
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
|
||||||
@@ -862,8 +802,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
}
|
}
|
||||||
priorBriefing := priorWeekendBriefing(priorResolved)
|
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
||||||
savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
|
||||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: ReportWeekend,
|
Report: ReportWeekend,
|
||||||
@@ -887,11 +826,12 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
t.Fatalf("GenerateReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Briefing.Weekend == nil {
|
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
|
||||||
t.Fatal("Weekend = nil")
|
if err != nil {
|
||||||
|
t.Fatalf("decode daypart summaries: %v", err)
|
||||||
}
|
}
|
||||||
if len(result.Briefing.Weekend.Days) != 3 {
|
if !ok || len(dayparts) == 0 {
|
||||||
t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days))
|
t.Fatalf("daypart summaries = %#v, want weekend module content", dayparts)
|
||||||
}
|
}
|
||||||
if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
|
if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
|
||||||
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
|
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
|
||||||
@@ -936,14 +876,11 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
|
|||||||
t.Fatalf("GenerateReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Briefing.Storm == nil {
|
|
||||||
t.Fatal("Storm = nil")
|
|
||||||
}
|
|
||||||
if renderer.renderRequest.PromptID != "weather.storm_report" {
|
if renderer.renderRequest.PromptID != "weather.storm_report" {
|
||||||
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
|
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
|
||||||
}
|
}
|
||||||
if len(result.Briefing.Storm.HourlyPeriods) == 0 || len(result.Briefing.Storm.NarrativePeriods) == 0 {
|
if _, ok := result.ModuleSnapshot.LookupStanza("precip_timing"); !ok {
|
||||||
t.Fatalf("selected source periods hourly/narrative = %d/%d, want relevant periods", len(result.Briefing.Storm.HourlyPeriods), len(result.Briefing.Storm.NarrativePeriods))
|
t.Fatal("precip_timing stanza missing")
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(outputPath); err != nil {
|
if _, err := os.Stat(outputPath); err != nil {
|
||||||
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
||||||
@@ -996,7 +933,7 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("InspectMetadata() error = %v", err)
|
t.Fatalf("InspectMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
if metadata.BriefingPath != result.BriefingPath || metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || metadata.DataPackagePath != result.DataPackagePath {
|
if metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || metadata.DataPackagePath != result.DataPackagePath {
|
||||||
t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata)
|
t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata)
|
||||||
}
|
}
|
||||||
moduleSnapshot, err := InspectModules(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
moduleSnapshot, err := InspectModules(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
||||||
@@ -1373,43 +1310,8 @@ func assertPathsExist(t *testing.T, paths ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) {
|
||||||
low := 50.0
|
|
||||||
high := 58.0
|
|
||||||
precip := 10.0
|
|
||||||
return briefing.Package{
|
|
||||||
Metadata: briefing.Metadata{
|
|
||||||
SchemaVersion: briefing.SchemaVersion,
|
|
||||||
RunID: resolved.Metadata().RunID,
|
|
||||||
ReportID: resolved.Definition.ID,
|
|
||||||
Variant: "today",
|
|
||||||
PromptID: resolved.Definition.PromptID,
|
|
||||||
GeneratedAt: resolved.GeneratedAt,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: resolved.Timezone,
|
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
|
||||||
},
|
|
||||||
Daily: &briefing.Daily{
|
|
||||||
BottomLine: briefing.BottomLine{
|
|
||||||
Temperature: forecast.Range{Min: &low, Max: &high},
|
|
||||||
MaxPrecipProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{
|
|
||||||
{Name: "morning"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, briefingPackage briefing.Package, snapshot module.Snapshot) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
|
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveModuleSnapshot() error = %v", err)
|
t.Fatalf("SaveModuleSnapshot() error = %v", err)
|
||||||
@@ -1418,8 +1320,7 @@ func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, bri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(resolved, briefingPackage, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadataFromBriefingMetadata(resolved, appBriefingMetadata(resolved), state.ArtifactPaths{
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: moduleSnapshotPath,
|
ModuleSnapshot: moduleSnapshotPath,
|
||||||
Metadata: paths.Metadata,
|
Metadata: paths.Metadata,
|
||||||
DataPackage: paths.DataPackage,
|
DataPackage: paths.DataPackage,
|
||||||
@@ -1480,61 +1381,16 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func priorThreeDayBriefing(resolved report.Resolved) briefing.Package {
|
func appBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||||
high := 58.0
|
return briefing.Metadata{
|
||||||
precip := 10.0
|
RunID: resolved.Metadata().RunID,
|
||||||
return briefing.Package{
|
ReportID: resolved.Definition.ID,
|
||||||
Metadata: briefing.Metadata{
|
Variant: "today",
|
||||||
SchemaVersion: briefing.SchemaVersion,
|
PromptID: resolved.Definition.PromptID,
|
||||||
RunID: resolved.Metadata().RunID,
|
GeneratedAt: resolved.GeneratedAt,
|
||||||
ReportID: resolved.Definition.ID,
|
Units: "us",
|
||||||
PromptID: resolved.Definition.PromptID,
|
Timezone: resolved.Timezone,
|
||||||
GeneratedAt: resolved.GeneratedAt,
|
ValidPeriod: resolved.ValidPeriod,
|
||||||
Units: "us",
|
|
||||||
Timezone: resolved.Timezone,
|
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
|
||||||
},
|
|
||||||
ThreeDay: &briefing.ThreeDay{
|
|
||||||
Days: []briefing.OutlookDay{
|
|
||||||
{
|
|
||||||
Date: "2026-05-29",
|
|
||||||
Temperature: forecast.Range{Max: &high},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func priorWeekendBriefing(resolved report.Resolved) briefing.Package {
|
|
||||||
high := 58.0
|
|
||||||
precip := 10.0
|
|
||||||
return briefing.Package{
|
|
||||||
Metadata: briefing.Metadata{
|
|
||||||
SchemaVersion: briefing.SchemaVersion,
|
|
||||||
RunID: resolved.Metadata().RunID,
|
|
||||||
ReportID: resolved.Definition.ID,
|
|
||||||
PromptID: resolved.Definition.PromptID,
|
|
||||||
GeneratedAt: resolved.GeneratedAt,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: resolved.Timezone,
|
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
|
||||||
},
|
|
||||||
Weekend: &briefing.Weekend{
|
|
||||||
Days: []briefing.OutlookDay{
|
|
||||||
{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Temperature: forecast.Range{Max: &high},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: resolved.ValidPeriod.Start.Add(12 * time.Hour),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1555,11 +1411,6 @@ type recordingStore struct {
|
|||||||
calls []string
|
calls []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingStore) SaveBriefing(ctx context.Context, resolved report.Resolved, pkg briefing.Package) (string, error) {
|
|
||||||
s.calls = append(s.calls, "briefing")
|
|
||||||
return s.Store.SaveBriefing(ctx, resolved, pkg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *recordingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
func (s *recordingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
||||||
s.calls = append(s.calls, "module_snapshot")
|
s.calls = append(s.calls, "module_snapshot")
|
||||||
return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
|
return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|
||||||
bundle := loadBundleFixture(t)
|
|
||||||
currentIsDay := true
|
|
||||||
currentTemp := 75.9
|
|
||||||
currentFeelsLike := 76.1
|
|
||||||
currentHumidity := 56.0
|
|
||||||
currentWind := 10.7
|
|
||||||
bundle.Current = &weatherdata.Current{
|
|
||||||
ConditionText: "Partly cloudy",
|
|
||||||
IsDay: ¤tIsDay,
|
|
||||||
TemperatureF: ¤tTemp,
|
|
||||||
ApparentTemperatureF: ¤tFeelsLike,
|
|
||||||
RelativeHumidityPercent: ¤tHumidity,
|
|
||||||
WindSpeedMph: ¤tWind,
|
|
||||||
}
|
|
||||||
bundle.Sources[0].DataSHA256 = "abc123"
|
|
||||||
bundle.Warnings = []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved := mustResolveDaily(t, location)
|
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildDaily(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Bundle: bundle,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
Location: &LocationContext{
|
|
||||||
ID: "home",
|
|
||||||
Name: "Brentwood",
|
|
||||||
Region: "St. Louis Metro",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
},
|
|
||||||
}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.SchemaVersion != SchemaVersion {
|
|
||||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
|
|
||||||
}
|
|
||||||
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
|
|
||||||
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
|
|
||||||
}
|
|
||||||
if pkg.Metadata.ReportID != report.DailyToday {
|
|
||||||
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
|
||||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
|
||||||
}
|
|
||||||
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
|
||||||
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
|
||||||
}
|
|
||||||
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
|
||||||
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
|
||||||
}
|
|
||||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
|
||||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
|
||||||
}
|
|
||||||
if len(pkg.Metadata.SourceWarnings) != 1 {
|
|
||||||
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
|
|
||||||
}
|
|
||||||
if pkg.Daily == nil {
|
|
||||||
t.Fatal("Daily = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.Dayparts) != 5 {
|
|
||||||
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.NarrativePeriods) != 1 {
|
|
||||||
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
|
||||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
|
||||||
}
|
|
||||||
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
|
||||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
|
||||||
}
|
|
||||||
if pkg.Daily.BottomLine.Summary == "" {
|
|
||||||
t.Fatal("BottomLine summary is empty")
|
|
||||||
}
|
|
||||||
if _, err := json.Marshal(pkg); err != nil {
|
|
||||||
t.Fatalf("briefing package is not JSON inspectable: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDailyBriefingQuietWeather(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved := mustResolveDaily(t, location)
|
|
||||||
bundle := &weatherdata.Bundle{
|
|
||||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
||||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
|
||||||
}},
|
|
||||||
Alerts: &weatherdata.AlertRun{},
|
|
||||||
Sources: []weatherdata.Source{
|
|
||||||
{Name: "hourly", FetchedAt: time.Now()},
|
|
||||||
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
|
||||||
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
|
||||||
},
|
|
||||||
Warnings: []weatherdata.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
|
||||||
}
|
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
||||||
}
|
|
||||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
|
||||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
|
||||||
}
|
|
||||||
if pkg.CurrentConditions != nil {
|
|
||||||
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
|
||||||
}
|
|
||||||
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
|
||||||
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if pkg.Metadata.Alerts == nil {
|
|
||||||
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
|
||||||
}
|
|
||||||
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
|
||||||
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(pkg.Metadata.Alerts)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal alert metadata: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(data), `"missing"`) {
|
|
||||||
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved := mustResolveDaily(t, location)
|
|
||||||
bundle := loadBundleFixture(t)
|
|
||||||
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
|
||||||
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
|
|
||||||
}}
|
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
||||||
}
|
|
||||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve tomorrow: %v", err)
|
|
||||||
}
|
|
||||||
precip := 70.0
|
|
||||||
wind := 34.0
|
|
||||||
summary := &forecast.DailySummary{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Period: resolved.ValidPeriod,
|
|
||||||
Dayparts: []forecast.DaypartSummary{
|
|
||||||
{
|
|
||||||
Name: "overnight",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T06:00:00-05:00"),
|
|
||||||
},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: 40,
|
|
||||||
Time: mustParse("2026-05-30T03:00:00-05:00"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "morning",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T06:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T12:00:00-05:00"),
|
|
||||||
},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: mustParse("2026-05-30T08:00:00-05:00"),
|
|
||||||
},
|
|
||||||
PeakWindGust: &forecast.TimedValue{
|
|
||||||
Value: wind,
|
|
||||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Indicators: forecast.Indicators{Snow: true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildDaily(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
|
|
||||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
|
|
||||||
}
|
|
||||||
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
|
|
||||||
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
|
|
||||||
}
|
|
||||||
if pkg.Daily.Planning == nil {
|
|
||||||
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
|
|
||||||
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
|
|
||||||
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveBriefingPackage(t *testing.T) {
|
|
||||||
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
|
|
||||||
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
|
|
||||||
if err := Save(path, pkg); err != nil {
|
|
||||||
t.Fatalf("Save() error = %v", err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read briefing: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), SchemaVersion) {
|
|
||||||
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadBundleFixture(t *testing.T) *weatherdata.Bundle {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read bundle fixture: %v", err)
|
|
||||||
}
|
|
||||||
var bundle weatherdata.Bundle
|
|
||||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
|
||||||
t.Fatalf("decode bundle fixture: %v", err)
|
|
||||||
}
|
|
||||||
return &bundle
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
|
||||||
t.Helper()
|
|
||||||
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve daily: %v", err)
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultDayparts() []forecast.DaypartDefinition {
|
|
||||||
return []forecast.DaypartDefinition{
|
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
|
||||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
|
||||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
|
||||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
|
||||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func quietHour(start string, end string, temperature float64) weatherdata.ForecastPeriod {
|
|
||||||
return weatherdata.ForecastPeriod{
|
|
||||||
StartTime: mustParse(start),
|
|
||||||
EndTime: mustParse(end),
|
|
||||||
TextDescription: "Clear",
|
|
||||||
TemperatureF: &temperature,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustLocation(t *testing.T) *time.Location {
|
|
||||||
t.Helper()
|
|
||||||
location, err := time.LoadLocation("America/Chicago")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load location: %v", err)
|
|
||||||
}
|
|
||||||
return location
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustParse(value string) time.Time {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,15 @@
|
|||||||
// Package briefing builds report-specific structured briefing packages.
|
// Package briefing builds prompt-facing module values.
|
||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
|
||||||
|
|
||||||
type Package struct {
|
|
||||||
Metadata Metadata `json:"metadata"`
|
|
||||||
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
|
||||||
Daily *Daily `json:"daily,omitempty"`
|
|
||||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
|
||||||
Weekend *Weekend `json:"weekend,omitempty"`
|
|
||||||
Storm *Storm `json:"storm,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
@@ -47,21 +33,6 @@ type LocationContext struct {
|
|||||||
Timezone string `json:"timezone,omitempty"`
|
Timezone string `json:"timezone,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CurrentConditionsContext struct {
|
|
||||||
ConditionText string `json:"conditionText,omitempty"`
|
|
||||||
IsDay *bool `json:"isDay,omitempty"`
|
|
||||||
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
|
||||||
TemperatureF *float64 `json:"temperatureF,omitempty"`
|
|
||||||
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
|
|
||||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
|
|
||||||
DewpointC *float64 `json:"dewpointC,omitempty"`
|
|
||||||
DewpointF *float64 `json:"dewpointF,omitempty"`
|
|
||||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
|
|
||||||
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
|
|
||||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
|
|
||||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SourceMetadata struct {
|
type SourceMetadata struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Endpoint string `json:"endpoint,omitempty"`
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
@@ -92,7 +63,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
metadata := ctx.Resolved.Metadata()
|
metadata := ctx.Resolved.Metadata()
|
||||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||||
return Metadata{
|
return Metadata{
|
||||||
SchemaVersion: SchemaVersion,
|
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: variantForReport(metadata.ReportID),
|
Variant: variantForReport(metadata.ReportID),
|
||||||
@@ -110,13 +80,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPackage(ctx BuildContext) Package {
|
|
||||||
return Package{
|
|
||||||
Metadata: BuildMetadata(ctx),
|
|
||||||
CurrentConditions: currentConditions(ctx.Bundle),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyLocation(location *LocationContext) *LocationContext {
|
func copyLocation(location *LocationContext) *LocationContext {
|
||||||
if location == nil {
|
if location == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -125,42 +88,6 @@ func copyLocation(location *LocationContext) *LocationContext {
|
|||||||
return &copied
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentConditions(bundle *weatherdata.Bundle) *CurrentConditionsContext {
|
|
||||||
if bundle == nil || bundle.Current == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
current := bundle.Current
|
|
||||||
context := CurrentConditionsContext{
|
|
||||||
ConditionText: current.ConditionText,
|
|
||||||
IsDay: copyBool(current.IsDay),
|
|
||||||
TemperatureC: copyFloat(current.TemperatureC),
|
|
||||||
TemperatureF: copyFloat(current.TemperatureF),
|
|
||||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
|
||||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
|
||||||
DewpointC: copyFloat(current.DewpointC),
|
|
||||||
DewpointF: copyFloat(current.DewpointF),
|
|
||||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
|
||||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
|
||||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
|
||||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
|
||||||
}
|
|
||||||
if context.ConditionText == "" &&
|
|
||||||
context.IsDay == nil &&
|
|
||||||
context.TemperatureC == nil &&
|
|
||||||
context.TemperatureF == nil &&
|
|
||||||
context.ApparentTemperatureC == nil &&
|
|
||||||
context.ApparentTemperatureF == nil &&
|
|
||||||
context.DewpointC == nil &&
|
|
||||||
context.DewpointF == nil &&
|
|
||||||
context.RelativeHumidityPercent == nil &&
|
|
||||||
context.WindSpeedKmh == nil &&
|
|
||||||
context.WindSpeedMph == nil &&
|
|
||||||
context.WindDirectionDegrees == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &context
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyBool(value *bool) *bool {
|
func copyBool(value *bool) *bool {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -177,11 +104,12 @@ func copyFloat(value *float64) *float64 {
|
|||||||
return &copied
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func Save(path string, pkg Package) error {
|
func copyTime(value *time.Time) *time.Time {
|
||||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
if value == nil {
|
||||||
return fmt.Errorf("save briefing package: %w", err)
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
copied := *value
|
||||||
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
||||||
@@ -247,16 +175,6 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func setRelevantAlertCount(metadata *Metadata, count int) {
|
|
||||||
if metadata.Alerts == nil {
|
|
||||||
if count == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
metadata.Alerts = &AlertStatus{}
|
|
||||||
}
|
|
||||||
metadata.Alerts.RelevantCount = count
|
|
||||||
}
|
|
||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.DailyToday:
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Storm struct {
|
|
||||||
TimingWindow timeutil.Period `json:"timingWindow"`
|
|
||||||
EventHeadlines []string `json:"eventHeadlines,omitempty"`
|
|
||||||
Hazards []string `json:"hazards,omitempty"`
|
|
||||||
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
|
|
||||||
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
|
|
||||||
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
|
|
||||||
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods,omitempty"`
|
|
||||||
DailyPeriods []weatherdata.ForecastPeriod `json:"dailyPeriods,omitempty"`
|
|
||||||
NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
|
||||||
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildStorm(ctx BuildContext, derived facts.DerivedFacts) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.Storm {
|
|
||||||
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
|
|
||||||
}
|
|
||||||
if ctx.Bundle == nil {
|
|
||||||
return Package{}, fmt.Errorf("forecast bundle is required")
|
|
||||||
}
|
|
||||||
period := ctx.Resolved.ValidPeriod
|
|
||||||
hourly := derived.ValidPeriodHourlyPeriods
|
|
||||||
narrative := derived.ValidPeriodNarrativePeriods
|
|
||||||
daily := derived.ValidPeriodDailyPeriods
|
|
||||||
alerts := derived.AlertOverlaps
|
|
||||||
if derived.StormWindowSummary == nil {
|
|
||||||
return Package{}, fmt.Errorf("storm window summary is required")
|
|
||||||
}
|
|
||||||
summary := *derived.StormWindowSummary
|
|
||||||
|
|
||||||
storm := &Storm{
|
|
||||||
TimingWindow: period,
|
|
||||||
EventHeadlines: stormHeadlines(alerts),
|
|
||||||
Hazards: stormHazards(alerts, summary),
|
|
||||||
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
|
|
||||||
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
|
|
||||||
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
|
|
||||||
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
|
|
||||||
RelevantAlerts: alerts,
|
|
||||||
HourlyPeriods: hourly,
|
|
||||||
DailyPeriods: daily,
|
|
||||||
NarrativePeriods: narrative,
|
|
||||||
WindowSummary: summary,
|
|
||||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.Storm = storm
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
|
||||||
var headlines []string
|
|
||||||
for _, alert := range alerts {
|
|
||||||
if alert.Headline != "" {
|
|
||||||
headlines = appendUnique(headlines, alert.Headline)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if alert.Event != "" {
|
|
||||||
headlines = appendUnique(headlines, alert.Event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(headlines) == 0 {
|
|
||||||
return []string{"No active alert headline overlaps the selected storm window."}
|
|
||||||
}
|
|
||||||
return headlines
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
|
||||||
hazards := map[string]struct{}{}
|
|
||||||
for _, alert := range alerts {
|
|
||||||
if alert.Event != "" {
|
|
||||||
hazards[alert.Event] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, hazard := range hazardsForIndicators(summary.Indicators) {
|
|
||||||
hazards[hazard] = struct{}{}
|
|
||||||
}
|
|
||||||
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
|
|
||||||
hazards["precipitation"] = struct{}{}
|
|
||||||
}
|
|
||||||
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
|
|
||||||
hazards["wind"] = struct{}{}
|
|
||||||
}
|
|
||||||
out := sortedSet(hazards)
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []string{"No storm-specific hazard signal stands out in the selected source data."}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func mostLikelyStormScenario(hourly []weatherdata.ForecastPeriod, narrative []weatherdata.ForecastPeriod, summary forecast.DaypartSummary) []string {
|
|
||||||
var items []string
|
|
||||||
if summary.DominantCondition != "" {
|
|
||||||
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
|
|
||||||
}
|
|
||||||
if summary.MaxPrecipitationProbability != nil {
|
|
||||||
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
|
|
||||||
}
|
|
||||||
if summary.PeakWindGust != nil {
|
|
||||||
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
|
|
||||||
}
|
|
||||||
for _, period := range narrative {
|
|
||||||
if period.TextDescription != "" {
|
|
||||||
items = append(items, "Narrative guidance: "+period.TextDescription)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(items) == 0 && len(hourly) > 0 {
|
|
||||||
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "No active storm signal is evident from the selected forecast window.")
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
|
||||||
var items []string
|
|
||||||
for _, alert := range alerts {
|
|
||||||
label := alert.Event
|
|
||||||
if label == "" {
|
|
||||||
label = alert.Headline
|
|
||||||
}
|
|
||||||
if label != "" {
|
|
||||||
items = appendUnique(items, "Alert scenario to consider: "+label+".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if summary.Indicators.Wind {
|
|
||||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
|
||||||
}
|
|
||||||
if summary.Indicators.Snow || summary.Indicators.Ice {
|
|
||||||
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormConfidenceInputs(bundle *weatherdata.Bundle) []string {
|
|
||||||
var items []string
|
|
||||||
if bundle == nil {
|
|
||||||
return []string{"No source bundle was available for confidence context."}
|
|
||||||
}
|
|
||||||
if bundle.Discussion != nil {
|
|
||||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
|
||||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
|
||||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bundle.WeatherStory != nil {
|
|
||||||
if bundle.WeatherStory.Title != "" {
|
|
||||||
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
|
|
||||||
} else {
|
|
||||||
items = appendUnique(items, "Weather story source is available.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, warning := range bundle.Warnings {
|
|
||||||
if warning.Code != "" {
|
|
||||||
items = appendUnique(items, "Source warning: "+warning.Code+".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *weatherdata.Bundle) []string {
|
|
||||||
var items []string
|
|
||||||
if len(alerts) > 0 {
|
|
||||||
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
|
|
||||||
}
|
|
||||||
if summary.MaxPrecipitationProbability != nil {
|
|
||||||
items = append(items, "Watch precipitation timing and probability trends.")
|
|
||||||
}
|
|
||||||
if summary.PeakWindGust != nil {
|
|
||||||
items = append(items, "Watch wind gust trends.")
|
|
||||||
}
|
|
||||||
if bundle != nil && bundle.Discussion != nil {
|
|
||||||
items = append(items, "Watch the next forecast discussion update for confidence changes.")
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
|
|
||||||
}
|
|
||||||
return appendUnique(nil, items...)
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve storm: %v", err)
|
|
||||||
}
|
|
||||||
precip := 80.0
|
|
||||||
gust := 42.0
|
|
||||||
bundle := &weatherdata.Bundle{
|
|
||||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{
|
|
||||||
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
|
|
||||||
TextDescription: "Severe thunderstorms and gusty wind",
|
|
||||||
ProbabilityOfPrecipitationPercent: &precip,
|
|
||||||
WindGustMph: &gust,
|
|
||||||
}}},
|
|
||||||
Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{
|
|
||||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
|
||||||
TextDescription: "Storms likely.",
|
|
||||||
}}},
|
|
||||||
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{
|
|
||||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
|
||||||
TextDescription: "Damaging wind possible in stronger storms.",
|
|
||||||
}}},
|
|
||||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
|
||||||
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
|
||||||
}},
|
|
||||||
Discussion: &weatherdata.Discussion{
|
|
||||||
Product: "discussion",
|
|
||||||
KeyMessages: []string{"Storms may intensify quickly."},
|
|
||||||
ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
|
||||||
LongTerm: &weatherdata.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
|
||||||
},
|
|
||||||
WeatherStory: &weatherdata.WeatherStory{
|
|
||||||
OfficeID: "LSX",
|
|
||||||
StartTime: mustParse("2026-05-29T06:00:00Z"),
|
|
||||||
EndTime: mustParse("2026-05-29T18:00:00Z"),
|
|
||||||
Title: "Storm Risk",
|
|
||||||
Description: "Strong storms are possible.",
|
|
||||||
AltText: "Weather story graphic showing storm risk.",
|
|
||||||
Order: 1,
|
|
||||||
},
|
|
||||||
Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, stormDerivedFacts(t, resolved, bundle))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildStorm() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.Storm {
|
|
||||||
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.Storm == nil {
|
|
||||||
t.Fatal("Storm = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
|
|
||||||
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
|
|
||||||
}
|
|
||||||
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
|
|
||||||
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
|
|
||||||
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
|
|
||||||
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
|
|
||||||
}
|
|
||||||
if pkg.Storm.WeatherStory == nil {
|
|
||||||
t.Fatal("WeatherStory = nil, want available story context")
|
|
||||||
}
|
|
||||||
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
|
|
||||||
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
|
|
||||||
}
|
|
||||||
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
|
||||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve storm: %v", err)
|
|
||||||
}
|
|
||||||
bundle := &weatherdata.Bundle{
|
|
||||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
|
|
||||||
Alerts: &weatherdata.AlertRun{},
|
|
||||||
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
|
|
||||||
Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, stormDerivedFacts(t, resolved, bundle))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildStorm() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pkg.Storm.RelevantAlerts) != 0 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
|
|
||||||
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
|
|
||||||
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.MostLikelyScenario) == 0 {
|
|
||||||
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStormBriefingQuietWindow(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve storm: %v", err)
|
|
||||||
}
|
|
||||||
bundle := &weatherdata.Bundle{
|
|
||||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
|
|
||||||
Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, stormDerivedFacts(t, resolved, bundle))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildStorm() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
|
|
||||||
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
|
|
||||||
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormDerivedFacts(t *testing.T, resolved report.Resolved, bundle *weatherdata.Bundle) facts.DerivedFacts {
|
|
||||||
t.Helper()
|
|
||||||
derived, err := facts.BuildDerived(facts.BuildDerivedRequest{
|
|
||||||
Resolved: resolved,
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
Dayparts: []forecast.DaypartDefinition{{Name: "morning", Start: "06:00", End: "12:00"}},
|
|
||||||
Collected: facts.BuildCollected(bundle),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build derived facts: %v", err)
|
|
||||||
}
|
|
||||||
return derived
|
|
||||||
}
|
|
||||||
@@ -5,121 +5,27 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daily struct {
|
|
||||||
BottomLine BottomLine `json:"bottomLine"`
|
|
||||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
|
||||||
Planning *TomorrowPlanning `json:"planning,omitempty"`
|
|
||||||
NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
ForecastSummaryDate string `json:"forecastSummaryDate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BottomLine struct {
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Hazards []string `json:"hazards,omitempty"`
|
|
||||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
|
||||||
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
|
||||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutdoorWindows struct {
|
type OutdoorWindows struct {
|
||||||
Best *OutdoorWindow `json:"best,omitempty"`
|
Best *OutdoorWindow
|
||||||
Worst *OutdoorWindow `json:"worst,omitempty"`
|
Worst *OutdoorWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutdoorWindow struct {
|
type OutdoorWindow struct {
|
||||||
Daypart string `json:"daypart"`
|
Daypart string
|
||||||
Start string `json:"start"`
|
Start string
|
||||||
End string `json:"end"`
|
End string
|
||||||
Reasons []string `json:"reasons,omitempty"`
|
Reasons []string
|
||||||
Score float64 `json:"score"`
|
Score float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type TomorrowPlanning struct {
|
type TomorrowPlanning struct {
|
||||||
MorningReadiness []string `json:"morningReadiness,omitempty"`
|
MorningReadiness []string
|
||||||
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
|
CommuteSchoolWorkdayConcerns []string
|
||||||
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
|
OvernightChangeWatch []string
|
||||||
}
|
|
||||||
|
|
||||||
type DiscussionContext struct {
|
|
||||||
Product string `json:"product,omitempty"`
|
|
||||||
KeyMessages []string `json:"keyMessages,omitempty"`
|
|
||||||
ShortTerm string `json:"shortTerm,omitempty"`
|
|
||||||
LongTerm string `json:"longTerm,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeatherStoryContext struct {
|
|
||||||
Available bool `json:"available"`
|
|
||||||
OfficeID string `json:"officeId,omitempty"`
|
|
||||||
StartTime time.Time `json:"startTime"`
|
|
||||||
EndTime time.Time `json:"endTime"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
AltText string `json:"altText,omitempty"`
|
|
||||||
Priority bool `json:"priority"`
|
|
||||||
Order int `json:"order"`
|
|
||||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
|
|
||||||
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
|
|
||||||
}
|
|
||||||
if summary == nil {
|
|
||||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.Daily = &Daily{
|
|
||||||
BottomLine: buildBottomLine(summary),
|
|
||||||
Dayparts: summary.Dayparts,
|
|
||||||
RelevantAlerts: summary.AlertOverlaps,
|
|
||||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
|
||||||
NarrativePeriods: summary.NarrativePeriods,
|
|
||||||
Discussion: buildDiscussion(summary.Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
ForecastSummaryDate: summary.Date,
|
|
||||||
}
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
|
||||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
|
||||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
|
||||||
}
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
|
|
||||||
bottomLine := BottomLine{}
|
|
||||||
conditions := map[string]struct{}{}
|
|
||||||
hazards := map[string]struct{}{}
|
|
||||||
for _, daypart := range summary.Dayparts {
|
|
||||||
addRange(&bottomLine.Temperature, daypart.Temperature)
|
|
||||||
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
|
|
||||||
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
|
|
||||||
if daypart.DominantCondition != "" {
|
|
||||||
conditions[daypart.DominantCondition] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
|
||||||
hazards[hazard] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, alert := range summary.AlertOverlaps {
|
|
||||||
if alert.Event != "" {
|
|
||||||
hazards[alert.Event] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bottomLine.Hazards = sortedSet(hazards)
|
|
||||||
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
|
|
||||||
return bottomLine
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||||
@@ -251,51 +157,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDiscussion(discussion *weatherdata.Discussion) DiscussionContext {
|
|
||||||
if discussion == nil {
|
|
||||||
return DiscussionContext{}
|
|
||||||
}
|
|
||||||
ctx := DiscussionContext{
|
|
||||||
Product: discussion.Product,
|
|
||||||
KeyMessages: discussion.KeyMessages,
|
|
||||||
}
|
|
||||||
if discussion.ShortTerm != nil {
|
|
||||||
ctx.ShortTerm = discussion.ShortTerm.Text
|
|
||||||
}
|
|
||||||
if discussion.LongTerm != nil {
|
|
||||||
ctx.LongTerm = discussion.LongTerm.Text
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildWeatherStory(bundle *weatherdata.Bundle) *WeatherStoryContext {
|
|
||||||
if bundle == nil || bundle.WeatherStory == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
story := bundle.WeatherStory
|
|
||||||
return &WeatherStoryContext{
|
|
||||||
Available: true,
|
|
||||||
OfficeID: story.OfficeID,
|
|
||||||
StartTime: story.StartTime,
|
|
||||||
EndTime: story.EndTime,
|
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
|
||||||
Title: story.Title,
|
|
||||||
Description: story.Description,
|
|
||||||
AltText: story.AltText,
|
|
||||||
Priority: story.Priority,
|
|
||||||
Order: story.Order,
|
|
||||||
DownloadURL: story.DownloadURL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyTime(value *time.Time) *time.Time {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
copied := *value
|
|
||||||
return &copied
|
|
||||||
}
|
|
||||||
|
|
||||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||||
score := 0.0
|
score := 0.0
|
||||||
reasons := []string{}
|
reasons := []string{}
|
||||||
@@ -336,20 +197,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func bottomLineText(conditions []string, hazards []string) string {
|
|
||||||
if len(conditions) == 0 && len(hazards) == 0 {
|
|
||||||
return "Quiet weather is expected."
|
|
||||||
}
|
|
||||||
parts := []string{}
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
|
|
||||||
}
|
|
||||||
if len(hazards) > 0 {
|
|
||||||
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ". ") + "."
|
|
||||||
}
|
|
||||||
|
|
||||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||||
var hazards []string
|
var hazards []string
|
||||||
if indicators.Snow {
|
if indicators.Snow {
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ThreeDay struct {
|
|
||||||
Days []OutlookDay `json:"days"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutlookDay struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Period timeutil.Period `json:"period"`
|
|
||||||
OverallCharacter string `json:"overallCharacter"`
|
|
||||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
|
||||||
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
|
||||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
|
||||||
Risks []string `json:"risks,omitempty"`
|
|
||||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.ThreeDay {
|
|
||||||
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
|
|
||||||
}
|
|
||||||
if len(summaries) == 0 {
|
|
||||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.ThreeDay = &ThreeDay{
|
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
}
|
|
||||||
for _, summary := range summaries {
|
|
||||||
day := buildOutlookDay(summary)
|
|
||||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
|
||||||
}
|
|
||||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
|
|
||||||
alerts := map[string]forecast.AlertOverlap{}
|
|
||||||
for _, day := range days {
|
|
||||||
for _, alert := range day.RelevantAlerts {
|
|
||||||
key := alert.Event
|
|
||||||
if key == "" {
|
|
||||||
key = alert.Headline
|
|
||||||
}
|
|
||||||
if key != "" {
|
|
||||||
alerts[key] = alert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(alerts))
|
|
||||||
for key := range alerts {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
out := make([]forecast.AlertOverlap, 0, len(keys))
|
|
||||||
for _, key := range keys {
|
|
||||||
out = append(out, alerts[key])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
|
|
||||||
day := OutlookDay{
|
|
||||||
Date: summary.Date,
|
|
||||||
Period: summary.Period,
|
|
||||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
|
||||||
RelevantAlerts: summary.AlertOverlaps,
|
|
||||||
Dayparts: summary.Dayparts,
|
|
||||||
}
|
|
||||||
conditions := map[string]struct{}{}
|
|
||||||
risks := map[string]struct{}{}
|
|
||||||
for _, daypart := range summary.Dayparts {
|
|
||||||
addRange(&day.Temperature, daypart.Temperature)
|
|
||||||
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
|
|
||||||
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
|
|
||||||
if daypart.DominantCondition != "" {
|
|
||||||
conditions[daypart.DominantCondition] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, risk := range hazardsForIndicators(daypart.Indicators) {
|
|
||||||
risks[risk] = struct{}{}
|
|
||||||
}
|
|
||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
|
||||||
risks["precipitation"] = struct{}{}
|
|
||||||
}
|
|
||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
|
||||||
risks["wind"] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, alert := range summary.AlertOverlaps {
|
|
||||||
if alert.Event != "" {
|
|
||||||
risks[alert.Event] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
day.Risks = sortedSet(risks)
|
|
||||||
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
|
|
||||||
return day
|
|
||||||
}
|
|
||||||
|
|
||||||
func outlookCharacter(conditions []string, risks []string) string {
|
|
||||||
if len(conditions) == 0 && len(risks) == 0 {
|
|
||||||
return "Quiet weather is expected."
|
|
||||||
}
|
|
||||||
parts := []string{}
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
parts = append(parts, strings.Join(conditions, "; "))
|
|
||||||
}
|
|
||||||
if len(risks) > 0 {
|
|
||||||
parts = append(parts, "risks: "+strings.Join(risks, "; "))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ". ") + "."
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 3-day: %v", err)
|
|
||||||
}
|
|
||||||
precip := 70.0
|
|
||||||
gust := 35.0
|
|
||||||
summaries := []forecast.DailySummary{
|
|
||||||
{
|
|
||||||
Date: "2026-05-29",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{
|
|
||||||
{
|
|
||||||
Name: "morning",
|
|
||||||
DominantCondition: "Showers and thunderstorms",
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: mustParse("2026-05-29T09:00:00-05:00"),
|
|
||||||
},
|
|
||||||
PeakWindGust: &forecast.TimedValue{
|
|
||||||
Value: gust,
|
|
||||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Indicators: forecast.Indicators{Wind: true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
|
||||||
Discussion: &weatherdata.Discussion{
|
|
||||||
Product: "discussion",
|
|
||||||
KeyMessages: []string{"Unsettled stretch."},
|
|
||||||
ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term rain chances remain focused today."},
|
|
||||||
LongTerm: &weatherdata.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildThreeDay(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
}, summaries)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildThreeDay() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.ThreeDay {
|
|
||||||
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.ThreeDay == nil {
|
|
||||||
t.Fatal("ThreeDay = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.ThreeDay.Days) != 2 {
|
|
||||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
|
||||||
}
|
|
||||||
first := pkg.ThreeDay.Days[0]
|
|
||||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
|
||||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
|
||||||
}
|
|
||||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Weekend struct {
|
|
||||||
Days []OutlookDay `json:"days"`
|
|
||||||
Planning WeekendPlanning `json:"planning"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeekendPlanning struct {
|
|
||||||
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
|
|
||||||
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
|
|
||||||
RainStormTiming []string `json:"rainStormTiming,omitempty"`
|
|
||||||
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
|
|
||||||
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.Weekend {
|
|
||||||
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
|
|
||||||
}
|
|
||||||
if len(summaries) == 0 {
|
|
||||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.Weekend = &Weekend{
|
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
}
|
|
||||||
for _, summary := range summaries {
|
|
||||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
|
||||||
}
|
|
||||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
|
||||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *weatherdata.Bundle) WeekendPlanning {
|
|
||||||
planning := WeekendPlanning{}
|
|
||||||
for _, day := range days {
|
|
||||||
if day.OutdoorWindows.Best != nil {
|
|
||||||
window := *day.OutdoorWindows.Best
|
|
||||||
window.Daypart = day.Date + " " + window.Daypart
|
|
||||||
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
|
|
||||||
}
|
|
||||||
if day.OutdoorWindows.Worst != nil {
|
|
||||||
window := *day.OutdoorWindows.Worst
|
|
||||||
window.Daypart = day.Date + " " + window.Daypart
|
|
||||||
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
|
|
||||||
}
|
|
||||||
for _, daypart := range day.Dayparts {
|
|
||||||
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
|
|
||||||
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
|
|
||||||
if discussion.ShortTerm != "" {
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
|
|
||||||
}
|
|
||||||
if discussion.LongTerm != "" {
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
|
|
||||||
}
|
|
||||||
if bundle != nil {
|
|
||||||
for _, warning := range bundle.Warnings {
|
|
||||||
if warning.Code != "" {
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(planning.RainStormTiming) == 0 {
|
|
||||||
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
|
|
||||||
}
|
|
||||||
if len(planning.ComfortConcerns) == 0 {
|
|
||||||
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
|
|
||||||
}
|
|
||||||
if len(planning.UncertaintyInputs) == 0 {
|
|
||||||
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
|
||||||
}
|
|
||||||
return planning
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
|
|
||||||
notes := []string{}
|
|
||||||
label := weekendWindowLabel(date, daypart.Name)
|
|
||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
|
||||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
|
||||||
}
|
|
||||||
return notes
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
|
|
||||||
notes := []string{}
|
|
||||||
label := weekendWindowLabel(date, daypart.Name)
|
|
||||||
if daypart.Indicators.Heat {
|
|
||||||
notes = append(notes, label+" heat may affect outdoor comfort.")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Cold {
|
|
||||||
notes = append(notes, label+" cold may affect outdoor comfort.")
|
|
||||||
}
|
|
||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
|
|
||||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
|
|
||||||
}
|
|
||||||
return notes
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendWindowLabel(date string, daypart string) string {
|
|
||||||
if daypart == "" {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(date + " " + daypart)
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve weekend: %v", err)
|
|
||||||
}
|
|
||||||
precip := 70.0
|
|
||||||
gust := 32.0
|
|
||||||
summaries := []forecast.DailySummary{
|
|
||||||
{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{
|
|
||||||
{
|
|
||||||
Name: "afternoon",
|
|
||||||
DominantCondition: "Showers and thunderstorms",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T12:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T18:00:00-05:00"),
|
|
||||||
},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: mustParse("2026-05-30T15:00:00-05:00"),
|
|
||||||
},
|
|
||||||
PeakWindGust: &forecast.TimedValue{
|
|
||||||
Value: gust,
|
|
||||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Indicators: forecast.Indicators{Wind: true},
|
|
||||||
HourlyPeriods: []weatherdata.ForecastPeriod{
|
|
||||||
{
|
|
||||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
|
|
||||||
TextDescription: "Showers and thunderstorms",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
|
||||||
Discussion: &weatherdata.Discussion{
|
|
||||||
Product: "discussion",
|
|
||||||
KeyMessages: []string{"Timing may shift."},
|
|
||||||
ShortTerm: &weatherdata.DiscussionSection{Text: "Short-term showers exit before the weekend."},
|
|
||||||
LongTerm: &weatherdata.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildWeekend(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
}, summaries)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildWeekend() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.Weekend {
|
|
||||||
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.Weekend == nil {
|
|
||||||
t.Fatal("Weekend = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.Weekend.Days) != 1 {
|
|
||||||
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
|
|
||||||
}
|
|
||||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
|
||||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
|
||||||
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
|
||||||
}
|
|
||||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
|
||||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
|
||||||
}
|
|
||||||
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -166,7 +166,7 @@ func TestLoadYAMLRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
|
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
|
||||||
}
|
}
|
||||||
if loaded.Briefing.Order[1] != "current_conditions" {
|
if loaded.Briefing.Order[1] != "current_conditions" {
|
||||||
t.Fatalf("loaded briefing order = %#v, want current_conditions second", loaded.Briefing.Order)
|
t.Fatalf("loaded package order = %#v, want current_conditions second", loaded.Briefing.Order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
@@ -28,7 +27,6 @@ type FilesystemStore struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ArtifactPaths struct {
|
type ArtifactPaths struct {
|
||||||
Briefing string `json:"briefing"`
|
|
||||||
ModuleSnapshot string `json:"moduleSnapshot"`
|
ModuleSnapshot string `json:"moduleSnapshot"`
|
||||||
Metadata string `json:"metadata"`
|
Metadata string `json:"metadata"`
|
||||||
DataPackage string `json:"dataPackage"`
|
DataPackage string `json:"dataPackage"`
|
||||||
@@ -46,7 +44,6 @@ type ReportRecord struct {
|
|||||||
ValidStart string `json:"validStart"`
|
ValidStart string `json:"validStart"`
|
||||||
ValidEnd string `json:"validEnd"`
|
ValidEnd string `json:"validEnd"`
|
||||||
MetadataPath string `json:"metadataPath"`
|
MetadataPath string `json:"metadataPath"`
|
||||||
BriefingPath string `json:"briefingPath"`
|
|
||||||
ReportPath string `json:"reportPath,omitempty"`
|
ReportPath string `json:"reportPath,omitempty"`
|
||||||
Warnings int `json:"warnings"`
|
Warnings int `json:"warnings"`
|
||||||
metadata Metadata
|
metadata Metadata
|
||||||
@@ -92,7 +89,6 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
|||||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
filenameBase := metadata.RunID
|
filenameBase := metadata.RunID
|
||||||
return ArtifactPaths{
|
return ArtifactPaths{
|
||||||
Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"),
|
|
||||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
|
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
|
||||||
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
|
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
|
||||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
|
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
|
||||||
@@ -102,17 +98,6 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolved, pkg briefing.Package) (string, error) {
|
|
||||||
paths, err := s.Paths(resolved)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return paths.Briefing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
||||||
paths, err := s.Paths(resolved)
|
paths, err := s.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,9 +163,6 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
|||||||
if metadata.RunID == "" {
|
if metadata.RunID == "" {
|
||||||
return "", fmt.Errorf("metadata run id is required")
|
return "", fmt.Errorf("metadata run id is required")
|
||||||
}
|
}
|
||||||
if metadata.BriefingPath == "" {
|
|
||||||
return "", fmt.Errorf("metadata briefing path is required")
|
|
||||||
}
|
|
||||||
if metadata.ModuleSnapshotPath == "" {
|
if metadata.ModuleSnapshotPath == "" {
|
||||||
return "", fmt.Errorf("metadata module snapshot path is required")
|
return "", fmt.Errorf("metadata module snapshot path is required")
|
||||||
}
|
}
|
||||||
@@ -253,7 +235,6 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
|||||||
})
|
})
|
||||||
return &PriorSnapshot{
|
return &PriorSnapshot{
|
||||||
Metadata: candidates[0],
|
Metadata: candidates[0],
|
||||||
BriefingPath: candidates[0].BriefingPath,
|
|
||||||
ModuleSnapshotPath: candidates[0].ModuleSnapshotPath,
|
ModuleSnapshotPath: candidates[0].ModuleSnapshotPath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -358,7 +339,6 @@ func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
|
|||||||
ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano),
|
ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano),
|
||||||
ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano),
|
ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano),
|
||||||
MetadataPath: path,
|
MetadataPath: path,
|
||||||
BriefingPath: metadata.BriefingPath,
|
|
||||||
ReportPath: metadata.RenderedReportPath,
|
ReportPath: metadata.RenderedReportPath,
|
||||||
Warnings: len(metadata.SourceWarnings),
|
Warnings: len(metadata.SourceWarnings),
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
@@ -438,14 +418,3 @@ func comparablePeriod(metadata Metadata, resolved report.Resolved) bool {
|
|||||||
func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool {
|
func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool {
|
||||||
return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start)
|
return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) {
|
|
||||||
if path == "" {
|
|
||||||
return briefing.Package{}, fmt.Errorf("briefing path is required")
|
|
||||||
}
|
|
||||||
var pkg briefing.Package
|
|
||||||
if err := readJSON(path, &pkg); err != nil {
|
|
||||||
return briefing.Package{}, err
|
|
||||||
}
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"),
|
|
||||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"),
|
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"),
|
||||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
|
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
|
||||||
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"),
|
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.yaml"),
|
||||||
@@ -44,7 +43,7 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
|||||||
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
briefingPackage := stateBriefingPackage(resolved)
|
briefingMetadata := stateBriefingMetadata(resolved)
|
||||||
snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": resolved.Metadata().RunID}}})
|
snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": resolved.Metadata().RunID}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSnapshot() error = %v", err)
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
@@ -53,7 +52,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
Metadata: promptinput.Metadata{
|
Metadata: promptinput.Metadata{
|
||||||
RunID: resolved.Metadata().RunID,
|
RunID: resolved.Metadata().RunID,
|
||||||
ReportID: resolved.Definition.ID,
|
ReportID: resolved.Definition.ID,
|
||||||
Variant: briefingPackage.Metadata.Variant,
|
Variant: briefingMetadata.Variant,
|
||||||
PromptID: resolved.Definition.PromptID,
|
PromptID: resolved.Definition.PromptID,
|
||||||
GeneratedAt: resolved.GeneratedAt,
|
GeneratedAt: resolved.GeneratedAt,
|
||||||
Timezone: resolved.Timezone,
|
Timezone: resolved.Timezone,
|
||||||
@@ -65,10 +64,6 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("Build() error = %v", err)
|
t.Fatalf("Build() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage)
|
dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveDataPackage() error = %v", err)
|
t.Fatalf("SaveDataPackage() error = %v", err)
|
||||||
@@ -131,8 +126,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
}
|
}
|
||||||
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
|
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: moduleSnapshotPath,
|
ModuleSnapshot: moduleSnapshotPath,
|
||||||
Metadata: paths.Metadata,
|
Metadata: paths.Metadata,
|
||||||
DataPackage: dataPackagePath,
|
DataPackage: dataPackagePath,
|
||||||
@@ -144,18 +138,11 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range []string{briefingPath, moduleSnapshotPath, dataPackagePath, preflightPath, notificationPath, renderedReportPath, metadataPath} {
|
for _, path := range []string{moduleSnapshotPath, dataPackagePath, preflightPath, notificationPath, renderedReportPath, metadataPath} {
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
t.Fatalf("expected artifact %q: %v", path, err)
|
t.Fatalf("expected artifact %q: %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadedBriefing, err := store.LoadBriefing(context.Background(), briefingPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
if loadedBriefing.Metadata.RunID != resolved.Metadata().RunID {
|
|
||||||
t.Fatalf("loaded briefing RunID = %q, want %q", loadedBriefing.Metadata.RunID, resolved.Metadata().RunID)
|
|
||||||
}
|
|
||||||
loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), moduleSnapshotPath)
|
loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), moduleSnapshotPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadModuleSnapshot() error = %v", err)
|
t.Fatalf("LoadModuleSnapshot() error = %v", err)
|
||||||
@@ -181,7 +168,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if decoded.RunID != resolved.Metadata().RunID {
|
if decoded.RunID != resolved.Metadata().RunID {
|
||||||
t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID)
|
t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID)
|
||||||
}
|
}
|
||||||
if decoded.BriefingPath != briefingPath || decoded.ModuleSnapshotPath != moduleSnapshotPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
|
if decoded.ModuleSnapshotPath != moduleSnapshotPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
|
||||||
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
|
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
|
||||||
}
|
}
|
||||||
if decoded.RenderedReportPath != renderedReportPath {
|
if decoded.RenderedReportPath != renderedReportPath {
|
||||||
@@ -198,17 +185,15 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
briefingPackage := stateBriefingPackage(resolved)
|
briefingMetadata := stateBriefingMetadata(resolved)
|
||||||
paths, err := store.Paths(resolved)
|
paths, err := store.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
}
|
}
|
||||||
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
||||||
otherBriefingPath := filepath.Join(otherDir, resolved.Metadata().RunID+".briefing.json")
|
|
||||||
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
||||||
|
|
||||||
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
|
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
||||||
Briefing: otherBriefingPath,
|
|
||||||
ModuleSnapshot: paths.ModuleSnapshot,
|
ModuleSnapshot: paths.ModuleSnapshot,
|
||||||
Metadata: paths.Metadata,
|
Metadata: paths.Metadata,
|
||||||
DataPackage: paths.DataPackage,
|
DataPackage: paths.DataPackage,
|
||||||
@@ -234,26 +219,7 @@ func TestFindPriorSnapshot(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
||||||
briefingPackage := stateBriefingPackage(first)
|
paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
paths, err := store.Paths(first)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Paths() error = %v", err)
|
|
||||||
}
|
|
||||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: paths.ModuleSnapshot,
|
|
||||||
Metadata: paths.Metadata,
|
|
||||||
DataPackage: paths.DataPackage,
|
|
||||||
Preflight: paths.Preflight,
|
|
||||||
RenderedReport: paths.RenderedReport,
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,9 +231,6 @@ func TestFindPriorSnapshot(t *testing.T) {
|
|||||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||||
}
|
}
|
||||||
if prior.BriefingPath != briefingPath {
|
|
||||||
t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath)
|
|
||||||
}
|
|
||||||
if prior.ModuleSnapshotPath != paths.ModuleSnapshot {
|
if prior.ModuleSnapshotPath != paths.ModuleSnapshot {
|
||||||
t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot)
|
t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot)
|
||||||
}
|
}
|
||||||
@@ -277,26 +240,7 @@ func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
||||||
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
briefingPackage := stateBriefingPackage(previousDate)
|
savePriorMetadata(t, store, previousDate, stateBriefingMetadata(previousDate))
|
||||||
briefingPath, err := store.SaveBriefing(context.Background(), previousDate, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
paths, err := store.Paths(previousDate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Paths() error = %v", err)
|
|
||||||
}
|
|
||||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(previousDate, briefingPackage, ArtifactPaths{
|
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: paths.ModuleSnapshot,
|
|
||||||
Metadata: paths.Metadata,
|
|
||||||
DataPackage: paths.DataPackage,
|
|
||||||
Preflight: paths.Preflight,
|
|
||||||
RenderedReport: paths.RenderedReport,
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -311,26 +255,7 @@ func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00")
|
first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00")
|
second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00")
|
||||||
briefingPackage := stateThreeDayBriefingPackage(first)
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
paths, err := store.Paths(first)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Paths() error = %v", err)
|
|
||||||
}
|
|
||||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: paths.ModuleSnapshot,
|
|
||||||
Metadata: paths.Metadata,
|
|
||||||
DataPackage: paths.DataPackage,
|
|
||||||
Preflight: paths.Preflight,
|
|
||||||
RenderedReport: paths.RenderedReport,
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -348,26 +273,7 @@ func TestFindPriorSnapshotSupportsWeekend(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
|
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
|
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
|
||||||
briefingPackage := stateWeekendBriefingPackage(first)
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
paths, err := store.Paths(first)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Paths() error = %v", err)
|
|
||||||
}
|
|
||||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: paths.ModuleSnapshot,
|
|
||||||
Metadata: paths.Metadata,
|
|
||||||
DataPackage: paths.DataPackage,
|
|
||||||
Preflight: paths.Preflight,
|
|
||||||
RenderedReport: paths.RenderedReport,
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -385,26 +291,7 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
|
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
|
||||||
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
|
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
|
||||||
briefingPackage := stateWeekendBriefingPackage(first)
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||||
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveBriefing() error = %v", err)
|
|
||||||
}
|
|
||||||
paths, err := store.Paths(first)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Paths() error = %v", err)
|
|
||||||
}
|
|
||||||
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
|
|
||||||
Briefing: briefingPath,
|
|
||||||
ModuleSnapshot: paths.ModuleSnapshot,
|
|
||||||
Metadata: paths.Metadata,
|
|
||||||
DataPackage: paths.DataPackage,
|
|
||||||
Preflight: paths.Preflight,
|
|
||||||
RenderedReport: paths.RenderedReport,
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -503,68 +390,46 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||||
return briefing.Package{
|
return briefing.Metadata{
|
||||||
Metadata: briefing.Metadata{
|
RunID: resolved.Metadata().RunID,
|
||||||
SchemaVersion: briefing.SchemaVersion,
|
ReportID: resolved.Definition.ID,
|
||||||
RunID: resolved.Metadata().RunID,
|
Variant: "today",
|
||||||
ReportID: resolved.Definition.ID,
|
PromptID: resolved.Definition.PromptID,
|
||||||
Variant: "today",
|
GeneratedAt: resolved.GeneratedAt,
|
||||||
PromptID: resolved.Definition.PromptID,
|
Units: "us",
|
||||||
GeneratedAt: resolved.GeneratedAt,
|
Timezone: resolved.Timezone,
|
||||||
Units: "us",
|
Location: &briefing.LocationContext{
|
||||||
Timezone: resolved.Timezone,
|
ID: "home",
|
||||||
Location: &briefing.LocationContext{
|
Name: "Brentwood",
|
||||||
ID: "home",
|
Region: "St. Louis Metro",
|
||||||
Name: "Brentwood",
|
Timezone: resolved.Timezone,
|
||||||
Region: "St. Louis Metro",
|
|
||||||
Timezone: resolved.Timezone,
|
|
||||||
},
|
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
|
||||||
},
|
},
|
||||||
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
ValidPeriod: resolved.ValidPeriod,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package {
|
func savePriorMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved, metadata briefing.Metadata) ArtifactPaths {
|
||||||
return briefing.Package{
|
t.Helper()
|
||||||
Metadata: briefing.Metadata{
|
paths, err := store.Paths(resolved)
|
||||||
SchemaVersion: briefing.SchemaVersion,
|
if err != nil {
|
||||||
RunID: resolved.Metadata().RunID,
|
t.Fatalf("Paths() error = %v", err)
|
||||||
ReportID: resolved.Definition.ID,
|
|
||||||
PromptID: resolved.Definition.PromptID,
|
|
||||||
GeneratedAt: resolved.GeneratedAt,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: resolved.Timezone,
|
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
|
||||||
},
|
|
||||||
ThreeDay: &briefing.ThreeDay{
|
|
||||||
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
_, err = store.SaveMetadata(context.Background(), BuildMetadataFromBriefingMetadata(resolved, metadata, ArtifactPaths{
|
||||||
|
ModuleSnapshot: paths.ModuleSnapshot,
|
||||||
func stateWeekendBriefingPackage(resolved report.Resolved) briefing.Package {
|
Metadata: paths.Metadata,
|
||||||
return briefing.Package{
|
DataPackage: paths.DataPackage,
|
||||||
Metadata: briefing.Metadata{
|
Preflight: paths.Preflight,
|
||||||
SchemaVersion: briefing.SchemaVersion,
|
RenderedReport: paths.RenderedReport,
|
||||||
RunID: resolved.Metadata().RunID,
|
}))
|
||||||
ReportID: resolved.Definition.ID,
|
if err != nil {
|
||||||
PromptID: resolved.Definition.PromptID,
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
GeneratedAt: resolved.GeneratedAt,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: resolved.Timezone,
|
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
|
||||||
},
|
|
||||||
Weekend: &briefing.Weekend{
|
|
||||||
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathsString(paths ArtifactPaths) string {
|
func pathsString(paths ArtifactPaths) string {
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
paths.Briefing,
|
|
||||||
paths.Metadata,
|
paths.Metadata,
|
||||||
paths.ModuleSnapshot,
|
paths.ModuleSnapshot,
|
||||||
paths.DataPackage,
|
paths.DataPackage,
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ type Metadata struct {
|
|||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
BriefingPath string `json:"briefingPath"`
|
|
||||||
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
||||||
DataPackagePath string `json:"dataPackagePath"`
|
DataPackagePath string `json:"dataPackagePath"`
|
||||||
PreflightPath string `json:"preflightPath"`
|
PreflightPath string `json:"preflightPath"`
|
||||||
@@ -34,10 +33,6 @@ type Metadata struct {
|
|||||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, paths ArtifactPaths) Metadata {
|
|
||||||
return BuildMetadataFromBriefingMetadata(resolved, briefingPackage.Metadata, paths)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
|
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
|
||||||
metadata := resolved.Metadata()
|
metadata := resolved.Metadata()
|
||||||
return Metadata{
|
return Metadata{
|
||||||
@@ -55,7 +50,6 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat
|
|||||||
SourceLocation: briefingMetadata.SourceLocation,
|
SourceLocation: briefingMetadata.SourceLocation,
|
||||||
Sources: briefingMetadata.Sources,
|
Sources: briefingMetadata.Sources,
|
||||||
SourceWarnings: briefingMetadata.SourceWarnings,
|
SourceWarnings: briefingMetadata.SourceWarnings,
|
||||||
BriefingPath: paths.Briefing,
|
|
||||||
ModuleSnapshotPath: paths.ModuleSnapshot,
|
ModuleSnapshotPath: paths.ModuleSnapshot,
|
||||||
DataPackagePath: paths.DataPackage,
|
DataPackagePath: paths.DataPackage,
|
||||||
PreflightPath: paths.Preflight,
|
PreflightPath: paths.Preflight,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -14,7 +13,6 @@ import (
|
|||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
Paths(report.Resolved) (ArtifactPaths, error)
|
Paths(report.Resolved) (ArtifactPaths, error)
|
||||||
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
|
||||||
SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error)
|
SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error)
|
||||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||||
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||||
@@ -22,13 +20,11 @@ type Store interface {
|
|||||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||||
SaveMetadata(context.Context, Metadata) (string, error)
|
SaveMetadata(context.Context, Metadata) (string, error)
|
||||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||||
LoadBriefing(context.Context, string) (briefing.Package, error)
|
|
||||||
LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
|
LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriorSnapshot struct {
|
type PriorSnapshot struct {
|
||||||
Metadata Metadata
|
Metadata Metadata
|
||||||
BriefingPath string
|
|
||||||
ModuleSnapshotPath string
|
ModuleSnapshotPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user