Remove obsolete briefing snapshot artifacts

This commit is contained in:
2026-06-09 21:33:34 +00:00
parent 816cfb24aa
commit 468197f7e0
26 changed files with 154 additions and 1981 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ Inputs:
- workspace configuration - workspace configuration
- resolved report definition and valid period - resolved report definition and valid period
- briefing package - module snapshot
- prompt input data package - prompt input data package
- preflight artifact - preflight artifact
- rendered report path preparation request - rendered report path preparation request
@@ -21,20 +21,20 @@ Inputs:
Outputs: Outputs:
- briefing snapshot JSON path - module snapshot JSON path
- prompt input data package JSON path - prompt input data package YAML path
- render preflight JSON path - render preflight JSON path
- managed Markdown report path - managed Markdown report path
- metadata JSON path - metadata JSON path
- prior comparable snapshot metadata - prior comparable snapshot metadata
- loaded briefing or data package - loaded module snapshot or data package
- recent report records for inspection - recent report records for inspection
## Boundaries ## Boundaries
`internal/state` owns local filesystem layout, path validation, durable writes, `internal/state` owns local filesystem layout, path validation, durable writes,
metadata reads, prior lookup, and report listing. It does not fetch weather metadata reads, prior lookup, and report listing. It does not fetch weather
data, derive forecasts, build prompt input content, compare briefing contents, data, derive forecasts, build prompt input content, compare module contents,
invoke Scriptorium, import adapter result types, or parse CLI flags. invoke Scriptorium, import adapter result types, or parse CLI flags.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID, RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
Variant: "today",
PromptID: resolved.Definition.PromptID, PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt, GeneratedAt: resolved.GeneratedAt,
Units: "us", Units: "us",
Timezone: resolved.Timezone, Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod, 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)

View File

@@ -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: &currentIsDay,
TemperatureF: &currentTemp,
ApparentTemperatureF: &currentFeelsLike,
RelativeHumidityPercent: &currentHumidity,
WindSpeedMph: &currentWind,
}
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
}

View File

@@ -1,29 +1,15 @@
// Package briefing builds report-specific structured briefing packages. // Package briefing builds prompt-facing module values.
package briefing package briefing
import ( import (
"fmt"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/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,12 +104,13 @@ 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) {
if bundle == nil { if bundle == nil {
@@ -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:

View File

@@ -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...)
}

View File

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

View File

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

View File

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

View File

@@ -1,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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,10 +390,8 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
return resolved return resolved
} }
func stateBriefingPackage(resolved report.Resolved) briefing.Package { func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Package{ return briefing.Metadata{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID, RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
Variant: "today", Variant: "today",
@@ -521,50 +406,30 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
Timezone: resolved.Timezone, Timezone: resolved.Timezone,
}, },
ValidPeriod: resolved.ValidPeriod, ValidPeriod: resolved.ValidPeriod,
},
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
} }
} }
func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package { func savePriorMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved, metadata briefing.Metadata) ArtifactPaths {
return briefing.Package{ t.Helper()
Metadata: briefing.Metadata{ paths, err := store.Paths(resolved)
SchemaVersion: briefing.SchemaVersion, if err != nil {
RunID: resolved.Metadata().RunID, t.Fatalf("Paths() error = %v", err)
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
ThreeDay: &briefing.ThreeDay{
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
},
} }
_, err = store.SaveMetadata(context.Background(), BuildMetadataFromBriefingMetadata(resolved, metadata, ArtifactPaths{
ModuleSnapshot: paths.ModuleSnapshot,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
} }
return paths
func stateWeekendBriefingPackage(resolved report.Resolved) briefing.Package {
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Weekend: &briefing.Weekend{
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
},
}
} }
func pathsString(paths ArtifactPaths) string { func pathsString(paths ArtifactPaths) string {
return strings.Join([]string{ return strings.Join([]string{
paths.Briefing,
paths.Metadata, paths.Metadata,
paths.ModuleSnapshot, paths.ModuleSnapshot,
paths.DataPackage, paths.DataPackage,

View File

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

View File

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