Add 3-Day Outlook generation

This commit is contained in:
2026-05-29 18:08:50 +00:00
parent 19513e42c1
commit 4c9d396f9b
24 changed files with 927 additions and 78 deletions

View File

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

View File

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