Add 3-Day Outlook generation
This commit is contained in:
@@ -3,15 +3,17 @@
|
||||
`weatherreporter` is a Go application for preparing human-facing weather
|
||||
reports from normalized forecast data.
|
||||
|
||||
The application can currently generate Daily Today and Daily Tomorrow Markdown
|
||||
reports through `scriptorium`, with inspectable briefing, prompt input,
|
||||
preflight, report, and metadata artifacts under the configured workspace.
|
||||
The application can currently generate Daily Today, Daily Tomorrow, and 3-Day
|
||||
Outlook Markdown reports through `scriptorium`, with inspectable briefing,
|
||||
prompt input, preflight, report, and metadata artifacts under the configured
|
||||
workspace.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
33
docs/cli.md
33
docs/cli.md
@@ -1,11 +1,12 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter generate daily`, `weatherreporter generate tomorrow`, and
|
||||
`weatherreporter run evening` currently write Daily-family Markdown reports
|
||||
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
|
||||
`weatherreporter generate three-day`, `weatherreporter run evening`, and the
|
||||
Sunday `weatherreporter run morning` path currently write Markdown reports
|
||||
through `scriptorium`, after writing managed preparation artifacts and running
|
||||
`scriptorium render` as a preflight check. Other report generation and scheduled
|
||||
runs still resolve configuration, report definitions, and valid periods, then
|
||||
return a not-implemented error.
|
||||
`scriptorium render` as a preflight check. Unsupported report generation and
|
||||
scheduled runs still resolve configuration, report definitions, and valid
|
||||
periods, then return a not-implemented error.
|
||||
|
||||
## Shortest Useful Command
|
||||
|
||||
@@ -27,6 +28,12 @@ weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
For the 3-Day Outlook:
|
||||
|
||||
```sh
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
```
|
||||
|
||||
## Command Overview
|
||||
|
||||
```text
|
||||
@@ -39,12 +46,14 @@ weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
`generate daily` and `generate tomorrow` write a briefing snapshot, prompt input
|
||||
data package, render preflight output, Markdown report, and metadata file under
|
||||
the configured workspace. `run evening` generates the Tomorrow Planning Brief.
|
||||
Other `generate` commands resolve one report request and stop before report
|
||||
generation. Other `run` commands resolve a scheduled batch request and stop
|
||||
before execution.
|
||||
`generate daily`, `generate tomorrow`, and `generate three-day` write a briefing
|
||||
snapshot, prompt input data package, render preflight output, Markdown report,
|
||||
and metadata file under the configured workspace. `run evening` generates the
|
||||
Tomorrow Planning Brief. `run morning` can generate Daily Today and the 3-Day
|
||||
Outlook when the resolved batch contains only implemented report types; when
|
||||
Weekend Outlook is part of the resolved batch, execution stops at the current
|
||||
not-implemented boundary. Other `generate` commands resolve one report request
|
||||
and stop before report generation.
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -52,7 +61,7 @@ before execution.
|
||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||
- `--units VALUE`: override configured Weather API units.
|
||||
- `--tz NAME`: override configured Weather API timezone.
|
||||
- `--out PATH`: optional Markdown report copy for `generate daily` and `generate tomorrow`; reserved for later generated report output on other `generate` commands.
|
||||
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, and `generate three-day`; reserved for later generated report output on other `generate` commands.
|
||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
- `--end TIME`: required end time for `generate storm`.
|
||||
|
||||
@@ -6,7 +6,7 @@ This document describes the implemented briefing package boundary.
|
||||
|
||||
`internal/briefing` builds structured report-specific briefing packages from
|
||||
forecast summaries and report metadata. The package currently implements Daily
|
||||
Today and Daily Tomorrow briefing content.
|
||||
Today, Daily Tomorrow, and 3-Day Outlook briefing content.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
@@ -14,12 +14,13 @@ Inputs:
|
||||
|
||||
- resolved report definition and valid period
|
||||
- forecast bundle
|
||||
- derived daily forecast summary
|
||||
- derived forecast summary or summaries
|
||||
- configured units and timezone
|
||||
|
||||
Output:
|
||||
|
||||
- `briefing.Package` JSON containing common metadata and Daily briefing content
|
||||
- `briefing.Package` JSON containing common metadata and report-specific
|
||||
briefing content
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -37,12 +38,18 @@ Output:
|
||||
story context when available.
|
||||
- Daily Tomorrow also includes planning inputs for morning readiness,
|
||||
commute/school/workday concerns, and what may change overnight.
|
||||
- 3-Day content includes one summary per local day or partial day, with overall
|
||||
character, temperature range, precipitation, wind, risk, outdoor-window, and
|
||||
alert inputs, plus broader discussion and weather-story context when
|
||||
available.
|
||||
- Briefing JSON is written atomically by `briefing.Save`.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily briefing construction requires a Daily report definition and a derived
|
||||
daily forecast summary.
|
||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
||||
one derived daily summary in the outlook period.
|
||||
- Save failures include path and operation context.
|
||||
|
||||
## Tests
|
||||
@@ -50,6 +57,7 @@ Output:
|
||||
Inspect:
|
||||
|
||||
- `internal/briefing/daily_test.go`
|
||||
- `internal/briefing/three_day_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ produces compact change records for prompt input data packages.
|
||||
|
||||
Inputs:
|
||||
|
||||
- prior Daily briefing package
|
||||
- current Daily briefing package
|
||||
- prior briefing package
|
||||
- current briefing package
|
||||
- configured Recent Changes thresholds
|
||||
|
||||
Output:
|
||||
@@ -37,7 +37,7 @@ The app maps these config fields into comparison thresholds:
|
||||
|
||||
## Behavior
|
||||
|
||||
Daily comparison currently detects:
|
||||
Daily and 3-Day comparison currently detect:
|
||||
|
||||
- temperature changes crossing configured thresholds
|
||||
- precipitation probability and timing changes
|
||||
@@ -47,17 +47,20 @@ Daily comparison currently detects:
|
||||
|
||||
When no prior comparable snapshot exists, the app sends an empty Recent Changes
|
||||
section in the data package. Daily Today and Daily Tomorrow are compatible for
|
||||
same-valid-date comparison through the report registry.
|
||||
same-valid-date comparison through the report registry. 3-Day Outlook compares
|
||||
with prior 3-Day Outlook snapshots for the same valid local date.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Daily comparison requires both inputs to contain Daily briefing content.
|
||||
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
|
||||
comparison requires both inputs to contain 3-Day briefing content.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/changes/daily_test.go`
|
||||
- `internal/changes/three_day_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -6,8 +6,8 @@ boundary.
|
||||
## Purpose
|
||||
|
||||
`internal/forecast` converts a normalized forecast bundle into inspectable
|
||||
daily daypart summaries. These summaries are structured data for later briefing
|
||||
builders; they are not rendered report text.
|
||||
daily and multi-day daypart summaries. These summaries are structured data for
|
||||
later briefing builders; they are not rendered report text.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
@@ -15,6 +15,7 @@ Inputs:
|
||||
|
||||
- `forecast.Bundle`
|
||||
- local date and timezone
|
||||
- report period, for multi-day summaries
|
||||
- configured daypart definitions with `HH:MM` start and end values
|
||||
|
||||
Output:
|
||||
@@ -22,6 +23,8 @@ Output:
|
||||
- `forecast.DailySummary` with a civil-day period, daypart summaries, selected
|
||||
narrative periods, alert overlaps, discussion context, source warnings, and
|
||||
source provenance.
|
||||
- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary
|
||||
for each local day or partial day in a report period.
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -41,6 +44,8 @@ Output:
|
||||
- Alerts are selected by overlap with the daily period and each daypart.
|
||||
- Narrative periods and discussion context are selected as broader source
|
||||
context for later briefing builders.
|
||||
- Multi-day period summaries clip the first and last local days to the resolved
|
||||
report period before selecting hourly periods and alerts.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ This document describes the implemented prompt input package boundary.
|
||||
|
||||
Input:
|
||||
|
||||
- `briefing.Package`
|
||||
- `briefing.Package` containing Daily-family or 3-Day Outlook content
|
||||
|
||||
Output:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ This document describes the implemented filesystem state boundary.
|
||||
## Purpose
|
||||
|
||||
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
|
||||
prior Daily snapshot lookup.
|
||||
prior comparable snapshot lookup.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
@@ -25,8 +25,8 @@ Outputs:
|
||||
- render preflight JSON
|
||||
- Markdown report path
|
||||
- metadata JSON
|
||||
- prior Daily snapshot metadata when available
|
||||
- prior Daily briefing package when loaded by path
|
||||
- prior comparable snapshot metadata when available
|
||||
- prior briefing package when loaded by path
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -48,12 +48,13 @@ Workspace subdirectories must be relative paths that stay under
|
||||
## State Behavior
|
||||
|
||||
Managed artifact names use RunID, which is generated from report generation time
|
||||
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
|
||||
local date. Prior Daily snapshot lookup reads metadata for the same valid local
|
||||
date and returns the latest earlier compatible Daily Today or Daily Tomorrow
|
||||
run. The store can load a briefing snapshot by path for structured comparison.
|
||||
The store prepares the managed Markdown report path before `scriptorium run`
|
||||
writes it.
|
||||
and report ID. Metadata is stored beside briefing snapshots by report group and
|
||||
valid local date. Prior snapshot lookup reads metadata for the same valid local
|
||||
date and returns the latest earlier compatible run. Daily Today and Daily
|
||||
Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
|
||||
3-Day Outlook snapshots. The store can load a briefing snapshot by path for
|
||||
structured comparison. The store prepares the managed Markdown report path
|
||||
before `scriptorium run` writes it.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
|
||||
## Normal Workflow
|
||||
|
||||
The implemented Daily-family generation workflows are:
|
||||
The implemented generation workflows are:
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate three-day
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
These commands fetch weather data, build a Daily briefing for the resolved
|
||||
valid date, build the prompt input data package, run `scriptorium render`, run
|
||||
These commands fetch weather data, build a briefing for the resolved valid
|
||||
period, build the prompt input data package, run `scriptorium render`, run
|
||||
`scriptorium run`, and write inspectable artifacts under the configured
|
||||
workspace. The evening run resolves only the Tomorrow Planning Brief.
|
||||
workspace. The evening run resolves only the Tomorrow Planning Brief. The
|
||||
morning run can generate Daily Today and the 3-Day Outlook when the resolved
|
||||
batch does not include an unsupported report type.
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
@@ -26,22 +29,34 @@ workspace/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
data-packages/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
preflight/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
three-day/
|
||||
<run_id>.md
|
||||
```
|
||||
|
||||
The Markdown report is written to a RunID-managed report path. When `--out` is
|
||||
provided to `generate daily` or `generate tomorrow`, the managed report is also
|
||||
copied to that path.
|
||||
provided to `generate daily`, `generate tomorrow`, or `generate three-day`, the
|
||||
managed report is also copied to that path.
|
||||
|
||||
## Run Identifiers
|
||||
|
||||
@@ -56,7 +71,7 @@ date do not overwrite each other.
|
||||
|
||||
## Metadata
|
||||
|
||||
Each Daily generation writes metadata that links:
|
||||
Each generated report writes metadata that links:
|
||||
|
||||
- RunID
|
||||
- report ID and prompt ID
|
||||
@@ -75,6 +90,9 @@ data package. Daily Today and Daily Tomorrow can compare with each other when
|
||||
they cover the same valid local date. Meaningful changes are included under
|
||||
`recentChanges.items`.
|
||||
|
||||
3-Day Outlook generation compares against a prior compatible 3-Day briefing
|
||||
snapshot for the same valid local date when one exists.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
the Recent Changes list is empty.
|
||||
|
||||
|
||||
@@ -58,13 +58,15 @@ type FetchBundleRequest struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyBriefingRequest struct {
|
||||
type BriefingRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyReportRequest struct {
|
||||
type DailyBriefingRequest = BriefingRequest
|
||||
|
||||
type ReportRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
@@ -72,12 +74,16 @@ type DailyReportRequest struct {
|
||||
Store state.Store
|
||||
}
|
||||
|
||||
type DailyBriefingResult struct {
|
||||
type DailyReportRequest = ReportRequest
|
||||
|
||||
type BriefingResult struct {
|
||||
Package briefing.Package
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyReportResult struct {
|
||||
type DailyBriefingResult = BriefingResult
|
||||
|
||||
type ReportResult struct {
|
||||
Briefing briefing.Package
|
||||
BriefingPath string
|
||||
DataPackage promptinput.Package
|
||||
@@ -93,6 +99,8 @@ type DailyReportResult struct {
|
||||
RunResult *scriptorium.RunResult
|
||||
}
|
||||
|
||||
type DailyReportResult = ReportResult
|
||||
|
||||
type Renderer interface {
|
||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||
@@ -107,8 +115,8 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isDailyReport(resolved.Definition.ID) {
|
||||
_, err := GenerateDailyReport(ctx, DailyReportRequest{
|
||||
if isGeneratedReport(resolved.Definition.ID) {
|
||||
_, err := GenerateReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: req.OutputPath,
|
||||
@@ -127,12 +135,14 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Batch == BatchEvening {
|
||||
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
||||
for _, resolved := range resolvedReports {
|
||||
if !isDailyReport(resolved.Definition.ID) {
|
||||
if !isGeneratedReport(resolved.Definition.ID) {
|
||||
return fmt.Errorf("run is not implemented")
|
||||
}
|
||||
if _, err := GenerateDailyReport(ctx, DailyReportRequest{
|
||||
}
|
||||
for _, resolved := range resolvedReports {
|
||||
if _, err := GenerateReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
}); err != nil {
|
||||
@@ -144,6 +154,10 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||
return fmt.Errorf("run is not implemented")
|
||||
}
|
||||
|
||||
func isGeneratedReport(id report.ID) bool {
|
||||
return isDailyReport(id) || id == report.ThreeDay
|
||||
}
|
||||
|
||||
func isDailyReport(id report.ID) bool {
|
||||
return id == report.DailyToday || id == report.DailyTomorrow
|
||||
}
|
||||
@@ -236,11 +250,15 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
|
||||
}
|
||||
|
||||
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
|
||||
return GenerateBriefing(ctx, req)
|
||||
}
|
||||
|
||||
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 := BuildDailyBriefing(req, bundle)
|
||||
pkg, err := BuildBriefing(req, bundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -259,10 +277,14 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail
|
||||
if err := briefing.Save(outputPath, pkg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||
}
|
||||
|
||||
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
|
||||
return GenerateReport(ctx, req)
|
||||
}
|
||||
|
||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||
store := req.Store
|
||||
if store == nil {
|
||||
defaultStore, err := defaultStore(req.Config)
|
||||
@@ -275,7 +297,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priorSnapshot, err := store.FindPriorDailySnapshot(ctx, req.Resolved)
|
||||
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -284,7 +306,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
briefingPackage, err := BuildDailyBriefing(DailyBriefingRequest{
|
||||
briefingPackage, err := BuildBriefing(BriefingRequest{
|
||||
Config: req.Config,
|
||||
Resolved: req.Resolved,
|
||||
}, bundle)
|
||||
@@ -297,7 +319,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recentChanges, err := dailyRecentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange)
|
||||
recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -375,7 +397,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
|
||||
return nil, runErr
|
||||
}
|
||||
|
||||
return &DailyReportResult{
|
||||
return &ReportResult{
|
||||
Briefing: briefingPackage,
|
||||
BriefingPath: briefingPath,
|
||||
DataPackage: dataPackage,
|
||||
@@ -393,6 +415,10 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
|
||||
}
|
||||
|
||||
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||
return BuildBriefing(req, bundle)
|
||||
}
|
||||
|
||||
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
@@ -405,16 +431,32 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie
|
||||
End: daypart.End,
|
||||
})
|
||||
}
|
||||
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
switch req.Resolved.Definition.ID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return briefing.BuildDaily(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
}, summary)
|
||||
case report.ThreeDay:
|
||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
}, summaries)
|
||||
default:
|
||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||
}
|
||||
return briefing.BuildDaily(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
}, summary)
|
||||
}
|
||||
|
||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
@@ -422,6 +464,10 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
}
|
||||
|
||||
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||
return recentChanges(ctx, store, priorSnapshot, current, cfg)
|
||||
}
|
||||
|
||||
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||
if priorSnapshot == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -429,12 +475,20 @@ func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changes.CompareDaily(previous, current, changes.Thresholds{
|
||||
thresholds := changes.Thresholds{
|
||||
TemperatureDegrees: cfg.TemperatureDegrees,
|
||||
PrecipProbabilityPoints: cfg.PrecipProbabilityPoints,
|
||||
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
|
||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||
})
|
||||
}
|
||||
switch current.Metadata.ReportID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
return changes.CompareDaily(previous, current, thresholds)
|
||||
case report.ThreeDay:
|
||||
return changes.CompareThreeDay(previous, current, thresholds)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func copyFileAtomic(source string, target string) error {
|
||||
|
||||
@@ -523,6 +523,81 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
priorResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportThreeDay,
|
||||
}, mustParse("2026-05-29T04:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorThreeDayBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
priorPaths, err := store.Paths(priorResolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
currentResolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportThreeDay,
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
||||
}
|
||||
renderer := &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
runResult: &scriptorium.RunResult{ExitCode: 0},
|
||||
runBody: "# 3-Day Outlook\n",
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Briefing.ThreeDay == nil {
|
||||
t.Fatal("ThreeDay = nil")
|
||||
}
|
||||
if len(result.Briefing.ThreeDay.Days) != 3 {
|
||||
t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days))
|
||||
}
|
||||
if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
|
||||
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
|
||||
}
|
||||
if result.PriorSnapshot == nil {
|
||||
t.Fatal("PriorSnapshot = nil, want prior 3-day snapshot")
|
||||
}
|
||||
if len(result.RecentChanges) == 0 {
|
||||
t.Fatal("RecentChanges length = 0, want changes from prior 3-day snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
@@ -652,6 +727,35 @@ func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type recordingRenderer struct {
|
||||
renderCalls int
|
||||
runCalls int
|
||||
|
||||
@@ -16,8 +16,9 @@ import (
|
||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
|
||||
121
internal/briefing/three_day.go
Normal file
121
internal/briefing/three_day.go
Normal file
@@ -0,0 +1,121 @@
|
||||
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 := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
ThreeDay: &ThreeDay{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
}
|
||||
alerts := map[string]forecast.AlertOverlap{}
|
||||
for _, summary := range summaries {
|
||||
day := buildOutlookDay(summary)
|
||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
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)
|
||||
for _, key := range keys {
|
||||
pkg.ThreeDay.RelevantAlerts = append(pkg.ThreeDay.RelevantAlerts, alerts[key])
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
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, ". ") + "."
|
||||
}
|
||||
83
internal/briefing/three_day_test.go
Normal file
83
internal/briefing/three_day_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve 3-day: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 35.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
Date: "2026-05-29",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "morning",
|
||||
DominantCondition: "Showers and thunderstorms",
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-29T09:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
|
||||
},
|
||||
{
|
||||
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, ","), "thunder") {
|
||||
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))
|
||||
}
|
||||
}
|
||||
78
internal/changes/three_day.go
Normal file
78
internal/changes/three_day.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
)
|
||||
|
||||
func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("previous 3-day briefing is required")
|
||||
}
|
||||
if current.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("current 3-day briefing is required")
|
||||
}
|
||||
previousDays := outlookDaysByDate(previous.ThreeDay.Days)
|
||||
currentDays := outlookDaysByDate(current.ThreeDay.Days)
|
||||
var changes []Change
|
||||
for date, currentDay := range currentDays {
|
||||
previousDay, ok := previousDays[date]
|
||||
if !ok {
|
||||
changes = append(changes, Change{Type: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
|
||||
continue
|
||||
}
|
||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...)
|
||||
}
|
||||
for date := range previousDays {
|
||||
if _, ok := currentDays[date]; !ok {
|
||||
changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
|
||||
}
|
||||
}
|
||||
sortChanges(changes)
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change {
|
||||
var changes []Change
|
||||
for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) {
|
||||
change.Message = date + ": " + change.Message
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) {
|
||||
change.Message = date + ": " + change.Message
|
||||
changes = append(changes, change)
|
||||
changes[len(changes)-1].Type = "outlook_" + change.Type
|
||||
}
|
||||
for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay {
|
||||
out := map[string]briefing.OutlookDay{}
|
||||
for _, day := range days {
|
||||
date := day.Date
|
||||
if date == "" && !day.Period.Start.IsZero() {
|
||||
date = day.Period.Start.Format(time.DateOnly)
|
||||
}
|
||||
if date != "" {
|
||||
out[date] = day
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
66
internal/changes/three_day_test.go
Normal file
66
internal/changes/three_day_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
previousPrecip := 20.0
|
||||
currentPrecip := 70.0
|
||||
previous := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-29",
|
||||
Temperature: forecast.Range{Max: &previousTemp},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: previousPrecip,
|
||||
Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}}},
|
||||
}
|
||||
current := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-29",
|
||||
Temperature: forecast.Range{Max: ¤tTemp},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: currentPrecip,
|
||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
||||
}}},
|
||||
}
|
||||
|
||||
changes, err := CompareThreeDay(previous, current, Thresholds{
|
||||
TemperatureDegrees: 5,
|
||||
PrecipProbabilityPoints: 20,
|
||||
PrecipTimingShiftMinutes: 120,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CompareThreeDay() error = %v", err)
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||
}
|
||||
var foundPrecip bool
|
||||
var foundThunder bool
|
||||
for _, change := range changes {
|
||||
if change.Type == "outlook_precip_probability_change" {
|
||||
foundPrecip = true
|
||||
}
|
||||
if change.Type == "outlook_thunder_risk_change" {
|
||||
foundThunder = true
|
||||
}
|
||||
}
|
||||
if !foundPrecip || !foundThunder {
|
||||
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{"generate", "three-day", "--units", "metric"}, &stdout, &stderr)
|
||||
err := runner.Run(context.Background(), []string{"generate", "weekend", "--units", "metric"}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want app not implemented error")
|
||||
}
|
||||
@@ -161,6 +161,86 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
outPath := filepath.Join(tempDir, "three-day.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"generate", "three-day",
|
||||
"--config", configPath,
|
||||
"--out", outPath,
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
report, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
data, err := os.ReadFile(dataPackageMatches[0])
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) {
|
||||
t.Fatalf("data package output missing 3-day content:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"run", "morning",
|
||||
"--config", configPath,
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob daily packages: %v", err)
|
||||
}
|
||||
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob 3-day packages: %v", err)
|
||||
}
|
||||
if len(dailyPackages) != 1 || len(threeDayPackages) != 1 {
|
||||
t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
|
||||
@@ -100,6 +100,62 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
|
||||
if !period.IsValid() {
|
||||
return nil, fmt.Errorf("valid forecast period is required")
|
||||
}
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
var summaries []DailySummary
|
||||
for day := timeutil.CivilDay(period.Start, location); day.Start.Before(period.End); day = timeutil.CivilDay(day.Start.AddDate(0, 0, 1), location) {
|
||||
overlap, ok := day.Intersection(period)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
summary, err := buildDailySummaryForPeriod(bundle, overlap, location, dayparts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries = append(summaries, *summary)
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("forecast bundle is required")
|
||||
}
|
||||
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
|
||||
return nil, fmt.Errorf("hourly forecast data is required")
|
||||
}
|
||||
windows, err := ResolveDayparts(period.Start, location, dayparts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts := AlertOverlaps(bundle.Alerts, period)
|
||||
summary := &DailySummary{
|
||||
Date: period.Start.In(location).Format(timeutil.DateLayout),
|
||||
Period: period,
|
||||
NarrativePeriods: SelectNarrativePeriods(bundle, period),
|
||||
AlertOverlaps: alerts,
|
||||
Discussion: SelectDiscussion(bundle),
|
||||
SourceWarnings: bundle.Warnings,
|
||||
SourceProvenance: bundle.Sources,
|
||||
}
|
||||
for _, window := range windows {
|
||||
clipped, ok := window.Period.Intersection(period)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
periods := SelectHourlyPeriods(bundle.Hourly, clipped)
|
||||
daypartSummary := SummarizeDaypart(window.Name, clipped, periods)
|
||||
daypartSummary.AlertOverlaps = overlapsWithin(alerts, clipped)
|
||||
summary.Dayparts = append(summary.Dayparts, daypartSummary)
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
|
||||
if run == nil {
|
||||
return nil
|
||||
|
||||
@@ -153,6 +153,41 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
|
||||
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
|
||||
hour(location, "2026-05-31T20:00:00-05:00", "2026-05-31T21:00:00-05:00", "Wind", 70, nil, nil, nil, ptr(35)),
|
||||
}}}
|
||||
period := timeutil.Period{
|
||||
Start: mustParse("2026-05-29T07:00:00-05:00").In(location),
|
||||
End: mustParse("2026-06-01T00:00:00-05:00").In(location),
|
||||
}
|
||||
|
||||
summaries, err := BuildPeriodDailySummaries(bundle, period, location, []DaypartDefinition{
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPeriodDailySummaries() error = %v", err)
|
||||
}
|
||||
if len(summaries) != 3 {
|
||||
t.Fatalf("summaries length = %d, want 3", len(summaries))
|
||||
}
|
||||
if summaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
|
||||
t.Fatalf("first period start = %s, want clipped start", summaries[0].Period.Start.Format(time.RFC3339))
|
||||
}
|
||||
if len(summaries[0].Dayparts[0].HourlyPeriods) != 1 || summaries[0].Dayparts[0].HourlyPeriods[0].TextDescription != "Showers" {
|
||||
t.Fatalf("first morning periods = %#v, want only post-start hour", summaries[0].Dayparts[0].HourlyPeriods)
|
||||
}
|
||||
if summaries[2].Dayparts[2].PeakWindGust == nil || summaries[2].Dayparts[2].PeakWindGust.Value != 35 {
|
||||
t.Fatalf("third evening gust = %#v, want 35", summaries[2].Dayparts[2].PeakWindGust)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertOverlap(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
||||
|
||||
@@ -107,8 +107,8 @@ func Validate(pkg Package) error {
|
||||
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
|
||||
return fmt.Errorf("briefing.metadata.reportId must match report.id")
|
||||
}
|
||||
if pkg.Briefing.Daily == nil {
|
||||
return fmt.Errorf("briefing.daily is required")
|
||||
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil {
|
||||
return fmt.Errorf("briefing report content is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,6 +52,29 @@ func TestValidateRequiresFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
||||
briefingPackage.Metadata.ReportID = report.ThreeDay
|
||||
briefingPackage.Metadata.PromptID = "weather.three_day_outlook"
|
||||
briefingPackage.Daily = nil
|
||||
briefingPackage.ThreeDay = &briefing.ThreeDay{
|
||||
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
|
||||
}
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Report.ID != report.ThreeDay {
|
||||
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
|
||||
}
|
||||
if pkg.Briefing.ThreeDay == nil {
|
||||
t.Fatal("Briefing.ThreeDay = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalDeterministic(t *testing.T) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
if err != nil {
|
||||
|
||||
@@ -151,7 +151,11 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
return s.FindPriorSnapshot(ctx, resolved)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -159,9 +163,6 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if group != "daily" {
|
||||
return nil, nil
|
||||
}
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -188,7 +189,7 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep
|
||||
if metadata.RunID == resolved.Metadata().RunID {
|
||||
continue
|
||||
}
|
||||
if metadata.ReportID != report.DailyToday && metadata.ReportID != report.DailyTomorrow {
|
||||
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
|
||||
continue
|
||||
}
|
||||
if !sameValidDate(metadata, resolved) {
|
||||
@@ -211,6 +212,17 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
|
||||
switch group {
|
||||
case "daily":
|
||||
return prior == report.DailyToday || prior == report.DailyTomorrow
|
||||
case "three-day":
|
||||
return prior == report.ThreeDay && current == report.ThreeDay
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) join(parts ...string) string {
|
||||
all := append([]string{s.root}, parts...)
|
||||
return filepath.Join(all...)
|
||||
|
||||
@@ -186,6 +186,42 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
if prior == nil {
|
||||
t.Fatal("FindPriorSnapshot() = nil, want prior 3-day snapshot")
|
||||
}
|
||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
||||
cfg := config.Defaults().Workspace
|
||||
cfg.Root = t.TempDir()
|
||||
@@ -231,6 +267,26 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved {
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
|
||||
t.Helper()
|
||||
location, err := timeutil.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation() error = %v", err)
|
||||
}
|
||||
now, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time: %v", err)
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.ThreeDay, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
return briefing.Package{
|
||||
Metadata: briefing.Metadata{
|
||||
@@ -248,6 +304,24 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
}
|
||||
}
|
||||
|
||||
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 pathsString(paths ArtifactPaths) string {
|
||||
return strings.Join([]string{
|
||||
paths.Briefing,
|
||||
|
||||
@@ -17,6 +17,7 @@ type Store interface {
|
||||
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
|
||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||
SaveMetadata(context.Context, Metadata) (string, error)
|
||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
LoadBriefing(context.Context, string) (briefing.Package, error)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,21 @@ func (p Period) Contains(t time.Time) bool {
|
||||
return !t.Before(p.Start) && t.Before(p.End)
|
||||
}
|
||||
|
||||
func (p Period) Intersection(other Period) (Period, bool) {
|
||||
if !p.Overlaps(other) {
|
||||
return Period{}, false
|
||||
}
|
||||
start := p.Start
|
||||
if other.Start.After(start) {
|
||||
start = other.Start
|
||||
}
|
||||
end := p.End
|
||||
if other.End.Before(end) {
|
||||
end = other.End
|
||||
}
|
||||
return Period{Start: start, End: end}, true
|
||||
}
|
||||
|
||||
func ParseClock(value string) (time.Duration, error) {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
|
||||
Reference in New Issue
Block a user