From 942e8ff591a285884810fe7345677ecf14ac6007 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 23:29:04 -0500 Subject: [PATCH] Added a field with the current local date to the data package --- docs/integrations/weatherapi.md | 2 +- docs/internal/prompt-input.md | 6 ++- internal/adapters/weatherapi/client_test.go | 8 ++-- .../weatherapi/testdata/discussion.json | 8 ++-- internal/app/app_test.go | 10 +++- internal/briefing/daily.go | 4 +- internal/briefing/storm.go | 2 +- internal/briefing/storm_test.go | 4 +- internal/briefing/three_day_test.go | 4 +- internal/briefing/weekend_test.go | 4 +- internal/forecast/bundle.go | 4 +- internal/forecast/testdata/daily_bundle.json | 8 ++-- internal/promptinput/package.go | 41 +++++++++++----- internal/promptinput/package_test.go | 47 +++++++++++++++++++ 14 files changed, 112 insertions(+), 40 deletions(-) diff --git a/docs/integrations/weatherapi.md b/docs/integrations/weatherapi.md index 04ec200..694896b 100644 --- a/docs/integrations/weatherapi.md +++ b/docs/integrations/weatherapi.md @@ -125,7 +125,7 @@ types in `internal/forecast/bundle.go`, including: - current condition values - forecast run metadata and `periods` - active alert run data -- discussion metadata, key messages, and short/long-term sections +- discussion metadata, key messages, and short/long-term section text The adapter intentionally keeps upstream transport and envelope details inside `internal/adapters/weatherapi`; downstream packages consume the normalized diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index aac3bc8..2efc0db 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -21,6 +21,8 @@ Outputs: includes configured location context, current conditions when available, discussion key messages, and short/long-term AFD narratives when the Weather API provides them. +- report metadata includes `currentLocalDate`, the generation date formatted as + `YYYY-MM-DD` in the effective report timezone. - optional JSON file written by `promptinput.Save` ## Boundaries @@ -31,8 +33,8 @@ Outputs: ## Config Fields Used -None directly. Config-derived values, including prompt location context, are -already present in briefing metadata before this package runs. +None directly. Config-derived values, including timezone and prompt location +context, are already present in briefing metadata before this package runs. ## External Adapters Used diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go index 8cd77c9..0f41af8 100644 --- a/internal/adapters/weatherapi/client_test.go +++ b/internal/adapters/weatherapi/client_test.go @@ -43,11 +43,11 @@ func TestFetchBundleFromFixtures(t *testing.T) { if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 { t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion) } - if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Narrative != "A weak boundary may trigger isolated showers." { - t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD narrative", bundle.Discussion.ShortTerm) + if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." { + t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm) } - if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Narrative != "Warmer temperatures and periodic rain chances continue into the weekend." { - t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD narrative", bundle.Discussion.LongTerm) + if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." { + t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm) } if len(bundle.Sources) != 8 { t.Fatalf("Sources length = %d, want 8", len(bundle.Sources)) diff --git a/internal/adapters/weatherapi/testdata/discussion.json b/internal/adapters/weatherapi/testdata/discussion.json index 67375c2..0084a42 100644 --- a/internal/adapters/weatherapi/testdata/discussion.json +++ b/internal/adapters/weatherapi/testdata/discussion.json @@ -9,12 +9,12 @@ "Warmer temperatures this weekend." ], "shortTerm": { - "title": "Short Term", - "narrative": "A weak boundary may trigger isolated showers." + "qualifier": "(Through This Evening)", + "text": "A weak boundary may trigger isolated showers." }, "longTerm": { - "title": "Long Term", - "narrative": "Warmer temperatures and periodic rain chances continue into the weekend." + "qualifier": "(This Weekend)", + "text": "Warmer temperatures and periodic rain chances continue into the weekend." } } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f7e956e..5f12c21 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -34,7 +34,7 @@ func TestFetchAndSaveBundle(t *testing.T) { case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) case "/discussion": - _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"title":"Short Term","narrative":"Short-term AFD narrative for saved bundle."},"longTerm":{"title":"Long Term","narrative":"Long-term AFD narrative for saved bundle."}}}`)) + _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`)) default: http.NotFound(w, r) } @@ -197,11 +197,17 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { t.Fatalf("data package missing expected content:\n%s", string(data)) } var savedDataPackage struct { + Report struct { + CurrentLocalDate string `json:"currentLocalDate"` + } `json:"report"` Briefing briefing.Package `json:"briefing"` } if err := json.Unmarshal(data, &savedDataPackage); err != nil { t.Fatalf("decode data package: %v", err) } + if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" { + t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate) + } location := savedDataPackage.Briefing.Metadata.Location if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" { t.Fatalf("data package location = %#v, want configured prompt location", location) @@ -899,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server { case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`)) case "/discussion": - _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"title":"Short Term","narrative":"Short-term AFD narrative for generated report."},"longTerm":{"title":"Long Term","narrative":"Long-term AFD narrative for generated report."}}}`)) + _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`)) default: http.NotFound(w, r) } diff --git a/internal/briefing/daily.go b/internal/briefing/daily.go index c8f5e4c..d47de16 100644 --- a/internal/briefing/daily.go +++ b/internal/briefing/daily.go @@ -249,10 +249,10 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext { KeyMessages: discussion.KeyMessages, } if discussion.ShortTerm != nil { - ctx.ShortTerm = discussion.ShortTerm.Narrative + ctx.ShortTerm = discussion.ShortTerm.Text } if discussion.LongTerm != nil { - ctx.LongTerm = discussion.LongTerm.Narrative + ctx.LongTerm = discussion.LongTerm.Text } return ctx } diff --git a/internal/briefing/storm.go b/internal/briefing/storm.go index 4084238..7b96842 100644 --- a/internal/briefing/storm.go +++ b/internal/briefing/storm.go @@ -158,7 +158,7 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string { } if bundle.Discussion != nil { items = appendUnique(items, bundle.Discussion.KeyMessages...) - if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" { + if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" { items = appendUnique(items, "Short-term discussion is available for confidence context.") } } diff --git a/internal/briefing/storm_test.go b/internal/briefing/storm_test.go index 9935528..0b02701 100644 --- a/internal/briefing/storm_test.go +++ b/internal/briefing/storm_test.go @@ -47,8 +47,8 @@ func TestStormBriefingWithActiveAlert(t *testing.T) { Discussion: &forecast.Discussion{ Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}, - ShortTerm: &forecast.DiscussionSection{Narrative: "Short-term storm coverage peaks this morning."}, - LongTerm: &forecast.DiscussionSection{Narrative: "Long-term pattern stays unsettled after the event."}, + ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."}, + LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."}, }, WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)}, Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, diff --git a/internal/briefing/three_day_test.go b/internal/briefing/three_day_test.go index ce267f0..64cc8bf 100644 --- a/internal/briefing/three_day_test.go +++ b/internal/briefing/three_day_test.go @@ -46,8 +46,8 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) { Discussion: &forecast.Discussion{ Product: "discussion", KeyMessages: []string{"Unsettled stretch."}, - ShortTerm: &forecast.DiscussionSection{Narrative: "Short-term rain chances remain focused today."}, - LongTerm: &forecast.DiscussionSection{Narrative: "Long-term warmth builds into the weekend."}, + ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."}, + LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."}, }, }, { diff --git a/internal/briefing/weekend_test.go b/internal/briefing/weekend_test.go index 2f8d48c..3478a21 100644 --- a/internal/briefing/weekend_test.go +++ b/internal/briefing/weekend_test.go @@ -57,8 +57,8 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) { Discussion: &forecast.Discussion{ Product: "discussion", KeyMessages: []string{"Timing may shift."}, - ShortTerm: &forecast.DiscussionSection{Narrative: "Short-term showers exit before the weekend."}, - LongTerm: &forecast.DiscussionSection{Narrative: "Long-term weekend rain timing remains uncertain."}, + ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."}, + LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."}, }, }, } diff --git a/internal/forecast/bundle.go b/internal/forecast/bundle.go index 9235b6c..1737948 100644 --- a/internal/forecast/bundle.go +++ b/internal/forecast/bundle.go @@ -149,8 +149,8 @@ type Discussion struct { } type DiscussionSection struct { - Title string `json:"title,omitempty"` - Narrative string `json:"narrative,omitempty"` + Qualifier string `json:"qualifier,omitempty"` + Text string `json:"text,omitempty"` IssuedAt *time.Time `json:"issuedAt,omitempty"` } diff --git a/internal/forecast/testdata/daily_bundle.json b/internal/forecast/testdata/daily_bundle.json index 0e1e496..3a3a5b5 100644 --- a/internal/forecast/testdata/daily_bundle.json +++ b/internal/forecast/testdata/daily_bundle.json @@ -56,12 +56,12 @@ "Storms are most likely during the morning." ], "shortTerm": { - "title": "Short Term", - "narrative": "Morning showers taper as a weak boundary shifts east." + "qualifier": "(Through This Evening)", + "text": "Morning showers taper as a weak boundary shifts east." }, "longTerm": { - "title": "Long Term", - "narrative": "Warmer and more humid conditions return with periodic rain chances." + "qualifier": "(This Weekend)", + "text": "Warmer and more humid conditions return with periodic rain chances." } }, "sources": [ diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index bd00257..6f8ccba 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -25,12 +25,13 @@ type Package struct { } type Report struct { - ID report.ID `json:"id"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"promptId"` - GeneratedAt time.Time `json:"generatedAt"` - Timezone string `json:"timezone"` - ValidPeriod timeutil.Period `json:"validPeriod"` + ID report.ID `json:"id"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"promptId"` + GeneratedAt time.Time `json:"generatedAt"` + Timezone string `json:"timezone"` + CurrentLocalDate string `json:"currentLocalDate"` + ValidPeriod timeutil.Period `json:"validPeriod"` } type RecentChanges struct { @@ -42,6 +43,10 @@ func Build(briefingPackage briefing.Package) (Package, error) { } func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) { + localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone) + if err != nil { + return Package{}, err + } items := make([]changes.Change, len(recentChanges)) copy(items, recentChanges) if items == nil { @@ -51,12 +56,13 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch SchemaVersion: SchemaVersion, RunID: briefingPackage.Metadata.RunID, Report: Report{ - ID: briefingPackage.Metadata.ReportID, - Variant: briefingPackage.Metadata.Variant, - PromptID: briefingPackage.Metadata.PromptID, - GeneratedAt: briefingPackage.Metadata.GeneratedAt, - Timezone: briefingPackage.Metadata.Timezone, - ValidPeriod: briefingPackage.Metadata.ValidPeriod, + ID: briefingPackage.Metadata.ReportID, + Variant: briefingPackage.Metadata.Variant, + PromptID: briefingPackage.Metadata.PromptID, + GeneratedAt: briefingPackage.Metadata.GeneratedAt, + Timezone: briefingPackage.Metadata.Timezone, + CurrentLocalDate: localDate, + ValidPeriod: briefingPackage.Metadata.ValidPeriod, }, Briefing: briefingPackage, RecentChanges: RecentChanges{Items: items}, @@ -68,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch return pkg, nil } +func currentLocalDate(generatedAt time.Time, timezone string) (string, error) { + location, err := timeutil.LoadLocation(timezone) + if err != nil { + return "", fmt.Errorf("load report timezone %q: %w", timezone, err) + } + return generatedAt.In(location).Format(timeutil.DateLayout), nil +} + func Validate(pkg Package) error { if pkg.SchemaVersion == "" { return fmt.Errorf("schemaVersion is required") @@ -87,6 +101,9 @@ func Validate(pkg Package) error { if pkg.Report.Timezone == "" { return fmt.Errorf("report.timezone is required") } + if pkg.Report.CurrentLocalDate == "" { + return fmt.Errorf("report.currentLocalDate is required") + } if !pkg.Report.ValidPeriod.IsValid() { return fmt.Errorf("report.validPeriod must be valid") } diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index 55466b1..5d82c63 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -28,6 +28,9 @@ func TestBuildDailyDataPackage(t *testing.T) { if pkg.Report.PromptID != "weather.daily_report" { t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) } + if pkg.Report.CurrentLocalDate != "2026-05-29" { + t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) + } if pkg.Briefing.Daily == nil { t.Fatal("Briefing.Daily = nil") } @@ -42,6 +45,34 @@ func TestBuildDailyDataPackage(t *testing.T) { } } +func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) { + briefingPackage := validBriefingPackage() + briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC) + briefingPackage.Metadata.Timezone = "America/Chicago" + + pkg, err := Build(briefingPackage) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if pkg.Report.CurrentLocalDate != "2026-05-29" { + t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate) + } +} + +func TestBuildRejectsInvalidReportTimezone(t *testing.T) { + briefingPackage := validBriefingPackage() + briefingPackage.Metadata.Timezone = "Not/AZone" + + _, err := Build(briefingPackage) + if err == nil { + t.Fatal("Build() error = nil, want invalid timezone error") + } + if !strings.Contains(err.Error(), "report timezone") { + t.Fatalf("error = %q, want report timezone context", err.Error()) + } +} + func TestValidateRequiresFields(t *testing.T) { pkg, err := Build(validBriefingPackage()) if err != nil { @@ -58,6 +89,22 @@ func TestValidateRequiresFields(t *testing.T) { } } +func TestValidateRequiresCurrentLocalDate(t *testing.T) { + pkg, err := Build(validBriefingPackage()) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + pkg.Report.CurrentLocalDate = "" + + err = Validate(pkg) + if err == nil { + t.Fatal("Validate() error = nil, want required field error") + } + if !strings.Contains(err.Error(), "currentLocalDate") { + t.Fatalf("error = %q, want currentLocalDate context", err.Error()) + } +} + func TestBuildThreeDayDataPackage(t *testing.T) { briefingPackage := validBriefingPackage() briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"