From 468197f7e07a661b6954ff476774055df2291ed6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 9 Jun 2026 21:33:34 +0000 Subject: [PATCH] Remove obsolete briefing snapshot artifacts --- docs/cli.md | 2 +- docs/internal/app-orchestration.md | 32 +- docs/internal/briefing.md | 45 +-- docs/internal/report-registry.md | 4 +- docs/internal/state.md | 16 +- docs/internal/weather-data.md | 4 +- docs/operations.md | 12 +- docs/policy/architecture.md | 6 +- docs/policy/development.md | 2 +- docs/troubleshooting.md | 2 +- internal/app/app.go | 85 ----- internal/app/app_test.go | 233 +++---------- internal/briefing/daily_test.go | 328 ------------------ internal/briefing/package.go | 94 +---- internal/briefing/storm.go | 205 ----------- internal/briefing/storm_test.go | 185 ---------- .../briefing/{daily.go => summary_helpers.go} | 173 +-------- internal/briefing/three_day.go | 128 ------- internal/briefing/three_day_test.go | 95 ----- internal/briefing/weekend.go | 122 ------- internal/briefing/weekend_test.go | 100 ------ internal/promptinput/package_test.go | 2 +- internal/state/filesystem.go | 31 -- internal/state/filesystem_test.go | 219 +++--------- internal/state/metadata.go | 6 - internal/state/store.go | 4 - 26 files changed, 154 insertions(+), 1981 deletions(-) delete mode 100644 internal/briefing/daily_test.go delete mode 100644 internal/briefing/storm.go delete mode 100644 internal/briefing/storm_test.go rename internal/briefing/{daily.go => summary_helpers.go} (58%) delete mode 100644 internal/briefing/three_day.go delete mode 100644 internal/briefing/three_day_test.go delete mode 100644 internal/briefing/weekend.go delete mode 100644 internal/briefing/weekend_test.go diff --git a/docs/cli.md b/docs/cli.md index ba9347e..d50cab3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -34,7 +34,7 @@ weatherreporter inspect prior [--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 an extra Markdown copy for the operator; distributor notification uses the managed report path, not the extra copy. `generate storm` requires explicit diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index d0d7f42..5f42967 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -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 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 distributor through an app-owned notifier boundary, persists managed state, runs batches, and reads existing artifacts for inspection. @@ -18,8 +18,7 @@ Inputs: - `GenerateRequest` for one report command - `BatchRequest` for morning or evening batch commands - `FetchBundleRequest` for explicit bundle fetch and save workflows -- `BriefingRequest` and `ReportRequest` for package-level orchestration tests - and internal composition +- `ReportRequest` for single-report generation - resolved report definitions from `internal/report` - weather data bundles from `internal/adapters/weatherapi` - prior snapshots loaded from `internal/state` @@ -27,13 +26,13 @@ Inputs: 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 notification result when attempted - batch summaries with per-report status, artifact paths, error text, and notification outcome when attempted - 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 ## Boundaries @@ -72,25 +71,24 @@ Single-report generation follows this order: 4. Fetch a Weather API bundle. 5. Build collected and derived facts once. 6. Execute configured modules and save the module snapshot. -7. Build and save the compatibility briefing snapshot. -8. Compute Recent Changes from structured prior and current module snapshots. -9. Build and save the YAML Scriptorium `data_package`. -10. Run Scriptorium render preflight. -11. Save preflight JSON when a render result is available. -12. Save metadata for inspection. -13. Run Scriptorium report generation to the managed report path. -14. Copy the managed report to the requested `--out` path when provided. -15. Save metadata with the managed report path. -16. If distributor notification is enabled, notify using the managed report +7. Compute Recent Changes from structured prior and current module snapshots. +8. Build and save the YAML Scriptorium `data_package`. +9. Run Scriptorium render preflight. +10. Save preflight JSON when a render result is available. +11. Save metadata for inspection. +12. Run Scriptorium report generation to the managed report path. +13. Copy the managed report to the requested `--out` path when provided. +14. Save metadata with the managed report path. +15. If distributor notification is enabled, notify using the managed report 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. If render preflight returns both a result and an error, preflight JSON and metadata are persisted before the error is returned. If Scriptorium report generation returns an error after writing output, the managed report and 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, including rendered pipeline ID, bundle paths, accepted upload fields, distributor status fields, raw status report JSON when available, and redacted diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index f1071d1..85a6298 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -4,9 +4,9 @@ This document describes the implemented briefing package boundary. ## Purpose -`internal/briefing` builds structured report-specific briefing packages from -resolved report metadata, collected weather data, and derived forecast facts. -Briefings are curated inputs for prompt data packages, not rendered report prose. +`internal/briefing` builds prompt-facing module values from resolved report +metadata, collected weather data, and derived forecast facts. These values are +curated prompt inputs, not rendered report prose or durable report snapshots. ## Inputs And Outputs @@ -19,23 +19,19 @@ Inputs: 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 shapes, fact requirements, report compatibility, and missing-data behavior - source-oriented module outputs for `metadata`, `current_conditions`, `alert_digest`, `area_forecast_discussion`, and `weather_story` -- optional `currentConditions` prompt context from normalized - `/conditions/current` data when available -- optional structured `weatherStory` context on report-specific briefing - objects when `/weatherstories/latest` is available -- optional JSON file written by `briefing.Save` +- derived module outputs such as daily summaries, daypart summaries, + precipitation timing, outdoor windows, and tomorrow planning +- optional current conditions and weather story module outputs when those + Weather API sources are available ## Boundaries - This package selects and shapes weather facts for prompts. -- It owns module registry validation, but app orchestration does not execute - modules yet. +- It owns module registry validation and module builder behavior. - It does not fetch weather data, compare prior snapshots, build `data_package` files, invoke Scriptorium, or write workflow metadata. @@ -54,23 +50,18 @@ None directly. ## State Or Manifest Behavior -`briefing.Save` writes briefing JSON atomically. Managed workspace placement is -owned by `internal/state`. +None. Module snapshots and prompt input data packages are persisted by +`internal/state` and composed by `internal/app`. ## 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 -- Daily briefing construction requires a Daily report definition and derived - daily summary. -- 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. +- Required derived modules return errors when their dependent facts are not + available. - Module registry construction rejects duplicate module IDs and duplicate stanza names. - 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. - Alert digest output distinguishes checked empty alert data from missing alert source data. -- Save failures include path and operation context. ## Tests 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/derived_modules_test.go` - `internal/briefing/modules_test.go` - `internal/app/app_test.go` ## 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 provenance, source hashes, source warnings, and configured prompt location. - LLM prompt input packaging and Scriptorium execution remain outside this diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index 575a73c..c5645bd 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -7,7 +7,7 @@ membership, output naming, artifact grouping, and comparison declarations in ## Purpose `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. ## Definition Fields @@ -55,7 +55,7 @@ must be after start time. ## Boundaries `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. The CLI owns public command names. The app maps those command names to report diff --git a/docs/internal/state.md b/docs/internal/state.md index 11747e9..a9c66b6 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -13,7 +13,7 @@ Inputs: - workspace configuration - resolved report definition and valid period -- briefing package +- module snapshot - prompt input data package - preflight artifact - rendered report path preparation request @@ -21,20 +21,20 @@ Inputs: Outputs: -- briefing snapshot JSON path -- prompt input data package JSON path +- module snapshot JSON path +- prompt input data package YAML path - render preflight JSON path - managed Markdown report path - metadata JSON path - prior comparable snapshot metadata -- loaded briefing or data package +- loaded module snapshot or data package - recent report records for inspection ## Boundaries `internal/state` owns local filesystem layout, path validation, durable writes, 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. 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 / - snapshots///.briefing.json + snapshots///.modules.json snapshots///.metadata.json data-packages///.data_package.yaml preflight///.render.json @@ -67,7 +67,7 @@ valid-period start date for dated artifacts, and the RunID. reports//.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 configured prompt location. Report listing walks metadata files under the 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 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 rather than creating state. diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index bbe3ca2..c916873 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -6,7 +6,7 @@ This document describes Weather API ingestion into `weatherdata.Bundle`. `internal/adapters/weatherapi` fetches normalized weather data from one 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. ## Inputs And Outputs @@ -29,7 +29,7 @@ Outputs: - The adapter owns HTTP calls, response-envelope handling, source hashing, and 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. ## Config Fields Used diff --git a/docs/operations.md b/docs/operations.md index c887723..25afb43 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -52,22 +52,18 @@ workspace/ snapshots/ daily/ YYYY-MM-DD/ - .briefing.json .modules.json .metadata.json three-day/ YYYY-MM-DD/ - .briefing.json .modules.json .metadata.json weekend/ YYYY-MM-DD/ - .briefing.json .modules.json .metadata.json storm/ YYYY-MM-DD/ - .briefing.json .modules.json .metadata.json data-packages/ @@ -136,7 +132,7 @@ Each generated report writes metadata that links: - RunID, report ID, variant, and prompt ID - generation time, timezone, and valid period - source location, source hashes, and source warnings -- briefing snapshot path +- module snapshot path - prompt input data package path - preflight output 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 destination merge, retention, and derived snapshot behavior such as `latest`. -Notification happens after final metadata save. Weather API, briefing, module -snapshot, data-package, render preflight, Scriptorium run, and metadata-save -failures do not trigger notification. A notification failure fails that report. +Notification happens after final metadata save. Weather API, module snapshot, +data-package, render preflight, Scriptorium run, and metadata-save failures do +not trigger notification. A notification failure fails that report. In a batch, other reports continue, the failed report includes notification fields in the JSON summary, and the batch returns nonzero. diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index 9fabfe1..a317d2c 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -5,11 +5,11 @@ This document defines the development principles for this Go project. It is inwa ## 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. -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. diff --git a/docs/policy/development.md b/docs/policy/development.md index eb538f0..36df912 100644 --- a/docs/policy/development.md +++ b/docs/policy/development.md @@ -23,7 +23,7 @@ Developers and LLM coding agents should use it with - `internal/module`: module IDs, config items, output envelopes, and snapshots. - `internal/report`: report definitions, valid periods, batches, output names, 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/promptinput`: Scriptorium `data_package` construction and validation. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index eea2e8a..edc4e69 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -195,7 +195,7 @@ Diagnostic: inspect stdout for the JSON summary and stderr for compact status lines. 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). diff --git a/internal/app/app.go b/internal/app/app.go index 052ffde..3c1d905 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -68,12 +68,6 @@ type FetchBundleRequest struct { OutputPath string } -type BriefingRequest struct { - Config config.Config - Resolved report.Resolved - OutputPath string -} - type ModuleSnapshotRequest struct { Config config.Config Resolved report.Resolved @@ -93,15 +87,8 @@ type ReportRequest struct { Notifier Notifier } -type BriefingResult struct { - Package briefing.Package - OutputPath string -} - type ReportResult struct { - Briefing briefing.Package ModuleSnapshot module.Snapshot - BriefingPath string ModuleSnapshotPath string DataPackage promptinput.Package DataPackagePath string @@ -142,7 +129,6 @@ type BatchReportResult struct { NotificationPath string `json:"notificationPath,omitempty"` GeneratedAt time.Time `json:"generatedAt"` ValidPeriod timeutil.Period `json:"validPeriod"` - BriefingPath string `json:"briefingPath,omitempty"` DataPackagePath string `json:"dataPackagePath,omitempty"` PreflightPath string `json:"preflightPath,omitempty"` ReportPath string `json:"reportPath,omitempty"` @@ -275,7 +261,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro for _, resolved := range resolvedReports { item := batchReportResult(resolved) if paths, err := store.Paths(resolved); err == nil { - item.BriefingPath = paths.Briefing item.DataPackagePath = paths.DataPackage item.PreflightPath = paths.Preflight item.ReportPath = paths.RenderedReport @@ -305,7 +290,6 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro result.Failed++ } else { item.Status = "succeeded" - item.BriefingPath = reportResult.BriefingPath item.DataPackagePath = reportResult.DataPackagePath item.PreflightPath = reportResult.PreflightPath item.ReportPath = reportResult.ReportPath @@ -450,33 +434,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda 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) { store := req.Store if store == nil { @@ -518,17 +475,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro if err != nil { 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) 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)) metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{ - Briefing: briefingPath, ModuleSnapshot: moduleSnapshotPath, Metadata: paths.Metadata, DataPackage: paths.DataPackage, @@ -629,9 +574,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro } return &ReportResult{ - Briefing: briefingPackage, ModuleSnapshot: moduleSnapshot, - BriefingPath: briefingPath, ModuleSnapshotPath: moduleSnapshotPath, DataPackage: dataPackage, DataPackagePath: dataPackagePath, @@ -846,34 +789,6 @@ func distributorUploadFiles(sourcePath string, bundlePaths []string) []distribut 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) { reportFacts, err := BuildReportFacts(req, bundle) if err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index edd7d01..f8ac846 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -15,7 +15,6 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" @@ -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) { server := dailyBundleServer(t) cfg := dailyTestConfig(t, server) @@ -202,10 +140,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if 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) } - 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) if err != nil { t.Fatalf("read module snapshot: %v", err) @@ -257,7 +195,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if 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) } if result.Metadata.RenderedReportPath != result.ReportPath { @@ -655,8 +593,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } - priorBriefing := priorDailyBriefing(priorResolved) - savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved)) + savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, @@ -721,14 +658,18 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) { t.Fatalf("GenerateReport() error = %v", err) } - if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" { - t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant) + if result.Metadata.ReportID != report.DailyTomorrow || result.Metadata.Variant != "tomorrow" { + t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant) } - if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" { - t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate) + dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary") + if err != nil { + t.Fatalf("decode daily summary: %v", err) } - if result.Briefing.Daily.Planning == nil { - t.Fatal("Planning = nil, want tomorrow planning inputs") + if !ok || dailySummary["date"] != "2026-05-30" { + 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") { t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath) @@ -753,8 +694,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) { if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } - priorBriefing := priorDailyBriefing(priorResolved) - savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved)) + savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, @@ -803,8 +743,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) { if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } - priorBriefing := priorThreeDayBriefing(priorResolved) - savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30")) + savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30")) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportThreeDay, @@ -828,11 +767,12 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) { t.Fatalf("GenerateReport() error = %v", err) } - if result.Briefing.ThreeDay == nil { - t.Fatal("ThreeDay = nil") + dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries") + if err != nil { + t.Fatalf("decode daypart summaries: %v", err) } - if len(result.Briefing.ThreeDay.Days) != 3 { - t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days)) + if !ok || len(dayparts) == 0 { + t.Fatalf("daypart summaries = %#v, want 3-day module content", dayparts) } if renderer.renderRequest.PromptID != "weather.three_day_outlook" { 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 { t.Fatalf("ResolveGenerate(prior) error = %v", err) } - priorBriefing := priorWeekendBriefing(priorResolved) - savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30")) + savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30")) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportWeekend, @@ -887,11 +826,12 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) { t.Fatalf("GenerateReport() error = %v", err) } - if result.Briefing.Weekend == nil { - t.Fatal("Weekend = nil") + dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries") + if err != nil { + t.Fatalf("decode daypart summaries: %v", err) } - if len(result.Briefing.Weekend.Days) != 3 { - t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days)) + if !ok || len(dayparts) == 0 { + t.Fatalf("daypart summaries = %#v, want weekend module content", dayparts) } if renderer.renderRequest.PromptID != "weather.weekend_outlook" { 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) } - if result.Briefing.Storm == nil { - t.Fatal("Storm = nil") - } if renderer.renderRequest.PromptID != "weather.storm_report" { 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 { - t.Fatalf("selected source periods hourly/narrative = %d/%d, want relevant periods", len(result.Briefing.Storm.HourlyPeriods), len(result.Briefing.Storm.NarrativePeriods)) + if _, ok := result.ModuleSnapshot.LookupStanza("precip_timing"); !ok { + t.Fatal("precip_timing stanza missing") } if _, err := os.Stat(outputPath); err != nil { t.Fatalf("expected requested report output %q: %v", outputPath, err) @@ -996,7 +933,7 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) { if err != nil { 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) } 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 { - 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) { +func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) { 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) if err != nil { 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 { t.Fatalf("Paths() error = %v", err) } - _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(resolved, briefingPackage, state.ArtifactPaths{ - Briefing: briefingPath, + _, err = store.SaveMetadata(context.Background(), state.BuildMetadataFromBriefingMetadata(resolved, appBriefingMetadata(resolved), state.ArtifactPaths{ ModuleSnapshot: moduleSnapshotPath, Metadata: paths.Metadata, DataPackage: paths.DataPackage, @@ -1480,61 +1381,16 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot { return snapshot } -func priorThreeDayBriefing(resolved report.Resolved) briefing.Package { - high := 58.0 - precip := 10.0 - return briefing.Package{ - Metadata: briefing.Metadata{ - SchemaVersion: briefing.SchemaVersion, - RunID: resolved.Metadata().RunID, - ReportID: resolved.Definition.ID, - PromptID: resolved.Definition.PromptID, - GeneratedAt: resolved.GeneratedAt, - Units: "us", - Timezone: resolved.Timezone, - ValidPeriod: resolved.ValidPeriod, - }, - ThreeDay: &briefing.ThreeDay{ - Days: []briefing.OutlookDay{ - { - Date: "2026-05-29", - Temperature: forecast.Range{Max: &high}, - MaxPrecipitationProbability: &forecast.TimedValue{ - Value: precip, - Time: resolved.ValidPeriod.Start.Add(6 * time.Hour), - }, - }, - }, - }, - } -} - -func priorWeekendBriefing(resolved report.Resolved) briefing.Package { - high := 58.0 - precip := 10.0 - return briefing.Package{ - Metadata: briefing.Metadata{ - SchemaVersion: briefing.SchemaVersion, - RunID: resolved.Metadata().RunID, - ReportID: resolved.Definition.ID, - PromptID: resolved.Definition.PromptID, - GeneratedAt: resolved.GeneratedAt, - Units: "us", - Timezone: resolved.Timezone, - ValidPeriod: resolved.ValidPeriod, - }, - Weekend: &briefing.Weekend{ - Days: []briefing.OutlookDay{ - { - Date: "2026-05-30", - Temperature: forecast.Range{Max: &high}, - MaxPrecipitationProbability: &forecast.TimedValue{ - Value: precip, - Time: resolved.ValidPeriod.Start.Add(12 * time.Hour), - }, - }, - }, - }, +func appBriefingMetadata(resolved report.Resolved) briefing.Metadata { + return briefing.Metadata{ + 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, } } @@ -1555,11 +1411,6 @@ type recordingStore struct { 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) { s.calls = append(s.calls, "module_snapshot") return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot) diff --git a/internal/briefing/daily_test.go b/internal/briefing/daily_test.go deleted file mode 100644 index 4c0ac44..0000000 --- a/internal/briefing/daily_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package briefing - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" -) - -func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { - bundle := loadBundleFixture(t) - currentIsDay := true - currentTemp := 75.9 - currentFeelsLike := 76.1 - currentHumidity := 56.0 - currentWind := 10.7 - bundle.Current = &weatherdata.Current{ - ConditionText: "Partly cloudy", - IsDay: ¤tIsDay, - TemperatureF: ¤tTemp, - ApparentTemperatureF: ¤tFeelsLike, - RelativeHumidityPercent: ¤tHumidity, - WindSpeedMph: ¤tWind, - } - bundle.Sources[0].DataSHA256 = "abc123" - bundle.Warnings = []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} - location := mustLocation(t) - resolved := mustResolveDaily(t, location) - summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) - if err != nil { - t.Fatalf("BuildDailySummary() error = %v", err) - } - - pkg, err := BuildDaily(BuildContext{ - Resolved: resolved, - Bundle: bundle, - Units: "us", - Timezone: "America/Chicago", - Location: &LocationContext{ - ID: "home", - Name: "Brentwood", - Region: "St. Louis Metro", - Timezone: "America/Chicago", - }, - }, summary) - if err != nil { - t.Fatalf("BuildDaily() error = %v", err) - } - - if pkg.Metadata.SchemaVersion != SchemaVersion { - t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion) - } - if !strings.Contains(pkg.Metadata.RunID, "daily_today") { - t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID) - } - if pkg.Metadata.ReportID != report.DailyToday { - t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID) - } - if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" { - t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone) - } - if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" { - t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location) - } - if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity { - t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions) - } - if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" { - t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources) - } - if len(pkg.Metadata.SourceWarnings) != 1 { - t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings)) - } - if pkg.Daily == nil { - t.Fatal("Daily = nil") - } - if len(pkg.Daily.Dayparts) != 5 { - t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts)) - } - if len(pkg.Daily.RelevantAlerts) != 1 { - t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts)) - } - if len(pkg.Daily.NarrativePeriods) != 1 { - t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods)) - } - if len(pkg.Daily.Discussion.KeyMessages) != 1 { - t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages)) - } - if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." { - t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm) - } - if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." { - t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm) - } - if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil { - t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows) - } - if pkg.Daily.BottomLine.Summary == "" { - t.Fatal("BottomLine summary is empty") - } - if _, err := json.Marshal(pkg); err != nil { - t.Fatalf("briefing package is not JSON inspectable: %v", err) - } -} - -func TestDailyBriefingQuietWeather(t *testing.T) { - location := mustLocation(t) - resolved := mustResolveDaily(t, location) - bundle := &weatherdata.Bundle{ - Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{ - quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), - }}, - Alerts: &weatherdata.AlertRun{}, - Sources: []weatherdata.Source{ - {Name: "hourly", FetchedAt: time.Now()}, - {Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()}, - {Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true}, - }, - Warnings: []weatherdata.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}}, - } - summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) - if err != nil { - t.Fatalf("BuildDailySummary() error = %v", err) - } - pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary) - if err != nil { - t.Fatalf("BuildDaily() error = %v", err) - } - if pkg.Daily.BottomLine.Summary != "Conditions: Clear." { - t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary) - } - if pkg.CurrentConditions != nil { - t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions) - } - if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" { - t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings) - } - if len(pkg.Daily.RelevantAlerts) != 0 { - t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) - } - if pkg.Metadata.Alerts == nil { - t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status") - } - if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing { - t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts) - } - data, err := json.Marshal(pkg.Metadata.Alerts) - if err != nil { - t.Fatalf("marshal alert metadata: %v", err) - } - if strings.Contains(string(data), `"missing"`) { - t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data)) - } -} - -func TestDailyBriefingAlertExclusion(t *testing.T) { - location := mustLocation(t) - resolved := mustResolveDaily(t, location) - bundle := loadBundleFixture(t) - bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{ - json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`), - }} - summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) - if err != nil { - t.Fatalf("BuildDailySummary() error = %v", err) - } - pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary) - if err != nil { - t.Fatalf("BuildDaily() error = %v", err) - } - if len(pkg.Daily.RelevantAlerts) != 0 { - t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) - } -} - -func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) { - location := mustLocation(t) - resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{ - Now: mustParse("2026-05-29T18:00:00-05:00"), - Location: location, - }) - if err != nil { - t.Fatalf("resolve tomorrow: %v", err) - } - precip := 70.0 - wind := 34.0 - summary := &forecast.DailySummary{ - Date: "2026-05-30", - Period: resolved.ValidPeriod, - Dayparts: []forecast.DaypartSummary{ - { - Name: "overnight", - Period: timeutil.Period{ - Start: mustParse("2026-05-30T00:00:00-05:00"), - End: mustParse("2026-05-30T06:00:00-05:00"), - }, - MaxPrecipitationProbability: &forecast.TimedValue{ - Value: 40, - Time: mustParse("2026-05-30T03:00:00-05:00"), - }, - }, - { - Name: "morning", - Period: timeutil.Period{ - Start: mustParse("2026-05-30T06:00:00-05:00"), - End: mustParse("2026-05-30T12:00:00-05:00"), - }, - MaxPrecipitationProbability: &forecast.TimedValue{ - Value: precip, - Time: mustParse("2026-05-30T08:00:00-05:00"), - }, - PeakWindGust: &forecast.TimedValue{ - Value: wind, - Time: mustParse("2026-05-30T09:00:00-05:00"), - }, - Indicators: forecast.Indicators{Snow: true}, - }, - }, - } - - pkg, err := BuildDaily(BuildContext{ - Resolved: resolved, - Units: "us", - Timezone: "America/Chicago", - }, summary) - if err != nil { - t.Fatalf("BuildDaily() error = %v", err) - } - - if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" { - t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant) - } - if pkg.Daily.ForecastSummaryDate != "2026-05-30" { - t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate) - } - if pkg.Daily.Planning == nil { - t.Fatal("Planning = nil, want tomorrow planning inputs") - } - if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 { - t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning) - } - if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") { - t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness) - } -} - -func TestSaveBriefingPackage(t *testing.T) { - pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}} - path := filepath.Join(t.TempDir(), "nested", "briefing.json") - if err := Save(path, pkg); err != nil { - t.Fatalf("Save() error = %v", err) - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read briefing: %v", err) - } - if !strings.Contains(string(data), SchemaVersion) { - t.Fatalf("saved briefing missing schema version:\n%s", string(data)) - } -} - -func loadBundleFixture(t *testing.T) *weatherdata.Bundle { - t.Helper() - data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json")) - if err != nil { - t.Fatalf("read bundle fixture: %v", err) - } - var bundle weatherdata.Bundle - if err := json.Unmarshal(data, &bundle); err != nil { - t.Fatalf("decode bundle fixture: %v", err) - } - return &bundle -} - -func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved { - t.Helper() - resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{ - Now: mustParse("2026-05-29T05:00:00-05:00"), - Location: location, - }) - if err != nil { - t.Fatalf("resolve daily: %v", err) - } - return resolved -} - -func defaultDayparts() []forecast.DaypartDefinition { - return []forecast.DaypartDefinition{ - {Name: "overnight", Start: "00:00", End: "06:00"}, - {Name: "morning", Start: "06:00", End: "10:00"}, - {Name: "midday", Start: "10:00", End: "15:00"}, - {Name: "afternoon", Start: "15:00", End: "17:00"}, - {Name: "evening", Start: "17:00", End: "24:00"}, - } -} - -func quietHour(start string, end string, temperature float64) weatherdata.ForecastPeriod { - return weatherdata.ForecastPeriod{ - StartTime: mustParse(start), - EndTime: mustParse(end), - TextDescription: "Clear", - TemperatureF: &temperature, - } -} - -func mustLocation(t *testing.T) *time.Location { - t.Helper() - location, err := time.LoadLocation("America/Chicago") - if err != nil { - t.Fatalf("load location: %v", err) - } - return location -} - -func mustParse(value string) time.Time { - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - panic(err) - } - return parsed -} diff --git a/internal/briefing/package.go b/internal/briefing/package.go index d8e5dc5..6c4685a 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -1,29 +1,15 @@ -// Package briefing builds report-specific structured briefing packages. +// Package briefing builds prompt-facing module values. package briefing import ( - "fmt" "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) -const SchemaVersion = "weatherreporter.briefing.v1" - -type Package struct { - Metadata Metadata `json:"metadata"` - CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"` - Daily *Daily `json:"daily,omitempty"` - ThreeDay *ThreeDay `json:"threeDay,omitempty"` - Weekend *Weekend `json:"weekend,omitempty"` - Storm *Storm `json:"storm,omitempty"` -} - type Metadata struct { - SchemaVersion string `json:"schemaVersion"` RunID string `json:"runId"` ReportID report.ID `json:"reportId"` Variant string `json:"variant,omitempty"` @@ -47,21 +33,6 @@ type LocationContext struct { 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 { Name string `json:"name"` Endpoint string `json:"endpoint,omitempty"` @@ -92,7 +63,6 @@ func BuildMetadata(ctx BuildContext) Metadata { metadata := ctx.Resolved.Metadata() sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) return Metadata{ - SchemaVersion: SchemaVersion, RunID: metadata.RunID, ReportID: 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 { if location == nil { return nil @@ -125,42 +88,6 @@ func copyLocation(location *LocationContext) *LocationContext { 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 { if value == nil { return nil @@ -177,11 +104,12 @@ func copyFloat(value *float64) *float64 { return &copied } -func Save(path string, pkg Package) error { - if err := fileutil.WriteJSONAtomic(path, pkg); err != nil { - return fmt.Errorf("save briefing package: %w", err) +func copyTime(value *time.Time) *time.Time { + if value == nil { + return nil } - return nil + copied := *value + return &copied } func sourceLocation(bundle *weatherdata.Bundle) (string, string) { @@ -247,16 +175,6 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus { 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 { switch id { case report.DailyToday: diff --git a/internal/briefing/storm.go b/internal/briefing/storm.go deleted file mode 100644 index 1aea99c..0000000 --- a/internal/briefing/storm.go +++ /dev/null @@ -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...) -} diff --git a/internal/briefing/storm_test.go b/internal/briefing/storm_test.go deleted file mode 100644 index a6a0d74..0000000 --- a/internal/briefing/storm_test.go +++ /dev/null @@ -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 -} diff --git a/internal/briefing/daily.go b/internal/briefing/summary_helpers.go similarity index 58% rename from internal/briefing/daily.go rename to internal/briefing/summary_helpers.go index 6ce3556..00d5c47 100644 --- a/internal/briefing/daily.go +++ b/internal/briefing/summary_helpers.go @@ -5,121 +5,27 @@ import ( "math" "sort" "strings" - "time" "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 { - Best *OutdoorWindow `json:"best,omitempty"` - Worst *OutdoorWindow `json:"worst,omitempty"` + Best *OutdoorWindow + Worst *OutdoorWindow } type OutdoorWindow struct { - Daypart string `json:"daypart"` - Start string `json:"start"` - End string `json:"end"` - Reasons []string `json:"reasons,omitempty"` - Score float64 `json:"score"` + Daypart string + Start string + End string + Reasons []string + Score float64 } type TomorrowPlanning struct { - MorningReadiness []string `json:"morningReadiness,omitempty"` - CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"` - OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"` -} - -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 + MorningReadiness []string + CommuteSchoolWorkdayConcerns []string + OvernightChangeWatch []string } func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { @@ -251,51 +157,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day 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 { score := 0.0 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 { var hazards []string if indicators.Snow { diff --git a/internal/briefing/three_day.go b/internal/briefing/three_day.go deleted file mode 100644 index f7c5849..0000000 --- a/internal/briefing/three_day.go +++ /dev/null @@ -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, ". ") + "." -} diff --git a/internal/briefing/three_day_test.go b/internal/briefing/three_day_test.go deleted file mode 100644 index 5b2aae1..0000000 --- a/internal/briefing/three_day_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/briefing/weekend.go b/internal/briefing/weekend.go deleted file mode 100644 index 15d734b..0000000 --- a/internal/briefing/weekend.go +++ /dev/null @@ -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) -} diff --git a/internal/briefing/weekend_test.go b/internal/briefing/weekend_test.go deleted file mode 100644 index 554cf6d..0000000 --- a/internal/briefing/weekend_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index aaebb6f..79e0ef1 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -166,7 +166,7 @@ func TestLoadYAMLRoundTrip(t *testing.T) { t.Fatalf("loaded package = %#v, want schema and run id", loaded) } 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) } } diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index b6a25af..70f5e63 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" @@ -28,7 +27,6 @@ type FilesystemStore struct { } type ArtifactPaths struct { - Briefing string `json:"briefing"` ModuleSnapshot string `json:"moduleSnapshot"` Metadata string `json:"metadata"` DataPackage string `json:"dataPackage"` @@ -46,7 +44,6 @@ type ReportRecord struct { ValidStart string `json:"validStart"` ValidEnd string `json:"validEnd"` MetadataPath string `json:"metadataPath"` - BriefingPath string `json:"briefingPath"` ReportPath string `json:"reportPath,omitempty"` Warnings int `json:"warnings"` metadata Metadata @@ -92,7 +89,6 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) validDate := resolved.ValidPeriod.Start.Format("2006-01-02") filenameBase := metadata.RunID return ArtifactPaths{ - Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"), ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"), Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"), 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 } -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) { paths, err := s.Paths(resolved) if err != nil { @@ -178,9 +163,6 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st if metadata.RunID == "" { return "", fmt.Errorf("metadata run id is required") } - if metadata.BriefingPath == "" { - return "", fmt.Errorf("metadata briefing path is required") - } if metadata.ModuleSnapshotPath == "" { return "", fmt.Errorf("metadata module snapshot path is required") } @@ -253,7 +235,6 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R }) return &PriorSnapshot{ Metadata: candidates[0], - BriefingPath: candidates[0].BriefingPath, ModuleSnapshotPath: candidates[0].ModuleSnapshotPath, }, nil } @@ -358,7 +339,6 @@ func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) { ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano), ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano), MetadataPath: path, - BriefingPath: metadata.BriefingPath, ReportPath: metadata.RenderedReportPath, Warnings: len(metadata.SourceWarnings), metadata: metadata, @@ -438,14 +418,3 @@ func comparablePeriod(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) } - -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 -} diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index fba7b4f..00dc7b9 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -27,7 +27,6 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) { } for _, want := range []string{ - filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.modules.json"), filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"), filepath.Join("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) { store := newTestStore(t) 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}}}) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) @@ -53,7 +52,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { Metadata: promptinput.Metadata{ RunID: resolved.Metadata().RunID, ReportID: resolved.Definition.ID, - Variant: briefingPackage.Metadata.Variant, + Variant: briefingMetadata.Variant, PromptID: resolved.Definition.PromptID, GeneratedAt: resolved.GeneratedAt, Timezone: resolved.Timezone, @@ -65,10 +64,6 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { 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) if err != nil { t.Fatalf("SaveDataPackage() error = %v", err) @@ -131,8 +126,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { if err != nil { t.Fatalf("Paths() error = %v", err) } - metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{ - Briefing: briefingPath, + metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ ModuleSnapshot: moduleSnapshotPath, Metadata: paths.Metadata, DataPackage: dataPackagePath, @@ -144,18 +138,11 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { 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 { 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) if err != nil { t.Fatalf("LoadModuleSnapshot() error = %v", err) @@ -181,7 +168,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { if 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) } if decoded.RenderedReportPath != renderedReportPath { @@ -198,17 +185,15 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) { store := newTestStore(t) resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - briefingPackage := stateBriefingPackage(resolved) + briefingMetadata := stateBriefingMetadata(resolved) paths, err := store.Paths(resolved) if err != nil { t.Fatalf("Paths() error = %v", err) } 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") - metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{ - Briefing: otherBriefingPath, + metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ ModuleSnapshot: paths.ModuleSnapshot, Metadata: paths.Metadata, DataPackage: paths.DataPackage, @@ -234,26 +219,7 @@ func TestFindPriorSnapshot(t *testing.T) { store := newTestStore(t) first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00") - briefingPackage := stateBriefingPackage(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) - } + paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first)) prior, err := store.FindPriorSnapshot(context.Background(), second) if err != nil { @@ -265,9 +231,6 @@ func TestFindPriorSnapshot(t *testing.T) { if 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 { t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot) } @@ -277,26 +240,7 @@ func TestFindPriorSnapshotUsesValidDate(t *testing.T) { store := newTestStore(t) previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - briefingPackage := stateBriefingPackage(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) - } + savePriorMetadata(t, store, previousDate, stateBriefingMetadata(previousDate)) prior, err := store.FindPriorSnapshot(context.Background(), currentDate) if err != nil { @@ -311,26 +255,7 @@ func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) { store := newTestStore(t) first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00") second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00") - briefingPackage := stateThreeDayBriefingPackage(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) - } + savePriorMetadata(t, store, first, stateBriefingMetadata(first)) prior, err := store.FindPriorSnapshot(context.Background(), second) if err != nil { @@ -348,26 +273,7 @@ func TestFindPriorSnapshotSupportsWeekend(t *testing.T) { store := newTestStore(t) first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00") second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00") - briefingPackage := stateWeekendBriefingPackage(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) - } + savePriorMetadata(t, store, first, stateBriefingMetadata(first)) prior, err := store.FindPriorSnapshot(context.Background(), second) if err != nil { @@ -385,26 +291,7 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) { store := newTestStore(t) first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00") second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00") - briefingPackage := stateWeekendBriefingPackage(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) - } + savePriorMetadata(t, store, first, stateBriefingMetadata(first)) prior, err := store.FindPriorSnapshot(context.Background(), second) if err != nil { @@ -503,68 +390,46 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved { return resolved } -func stateBriefingPackage(resolved report.Resolved) briefing.Package { - 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, - Location: &briefing.LocationContext{ - ID: "home", - Name: "Brentwood", - Region: "St. Louis Metro", - Timezone: resolved.Timezone, - }, - ValidPeriod: resolved.ValidPeriod, +func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata { + return briefing.Metadata{ + RunID: resolved.Metadata().RunID, + ReportID: resolved.Definition.ID, + Variant: "today", + PromptID: resolved.Definition.PromptID, + GeneratedAt: resolved.GeneratedAt, + Units: "us", + Timezone: resolved.Timezone, + Location: &briefing.LocationContext{ + ID: "home", + Name: "Brentwood", + Region: "St. Louis Metro", + Timezone: resolved.Timezone, }, - Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"}, + ValidPeriod: resolved.ValidPeriod, } } -func stateThreeDayBriefingPackage(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, - }, - ThreeDay: &briefing.ThreeDay{ - Days: []briefing.OutlookDay{{Date: "2026-05-29"}}, - }, +func savePriorMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved, metadata briefing.Metadata) ArtifactPaths { + t.Helper() + paths, err := store.Paths(resolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) } -} - -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"}}, - }, + _, 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 pathsString(paths ArtifactPaths) string { return strings.Join([]string{ - paths.Briefing, paths.Metadata, paths.ModuleSnapshot, paths.DataPackage, diff --git a/internal/state/metadata.go b/internal/state/metadata.go index 56c9f2b..c935347 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -26,7 +26,6 @@ type Metadata struct { SourceLocation string `json:"sourceLocation,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` - BriefingPath string `json:"briefingPath"` ModuleSnapshotPath string `json:"moduleSnapshotPath"` DataPackagePath string `json:"dataPackagePath"` PreflightPath string `json:"preflightPath"` @@ -34,10 +33,6 @@ type Metadata struct { 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 { metadata := resolved.Metadata() return Metadata{ @@ -55,7 +50,6 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat SourceLocation: briefingMetadata.SourceLocation, Sources: briefingMetadata.Sources, SourceWarnings: briefingMetadata.SourceWarnings, - BriefingPath: paths.Briefing, ModuleSnapshotPath: paths.ModuleSnapshot, DataPackagePath: paths.DataPackage, PreflightPath: paths.Preflight, diff --git a/internal/state/store.go b/internal/state/store.go index 91c74e0..40cd3c9 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -6,7 +6,6 @@ import ( "encoding/json" "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" @@ -14,7 +13,6 @@ import ( type Store interface { Paths(report.Resolved) (ArtifactPaths, error) - SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error) SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error) @@ -22,13 +20,11 @@ type Store interface { PrepareRenderedReport(context.Context, report.Resolved) (string, error) SaveMetadata(context.Context, Metadata) (string, error) FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) - LoadBriefing(context.Context, string) (briefing.Package, error) LoadModuleSnapshot(context.Context, string) (module.Snapshot, error) } type PriorSnapshot struct { Metadata Metadata - BriefingPath string ModuleSnapshotPath string }