From 0b050256f909f5bfad52a17f4c33998c09bc6971 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 20:04:27 -0500 Subject: [PATCH] Add a current conditions block to the briefing/data-package --- docs/internal/briefing.md | 4 ++ docs/internal/prompt-input.md | 5 +- docs/internal/weather-data.md | 3 +- internal/app/app_test.go | 6 +- internal/briefing/daily.go | 22 ++++--- internal/briefing/daily_test.go | 30 +++++++++- internal/briefing/package.go | 85 ++++++++++++++++++++++++++-- internal/briefing/storm.go | 6 +- internal/briefing/three_day.go | 10 ++-- internal/briefing/weekend.go | 10 ++-- internal/promptinput/package_test.go | 6 ++ 11 files changed, 147 insertions(+), 40 deletions(-) diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 093c93a..7af0182 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -22,6 +22,8 @@ Outputs: - `briefing.Package` with common metadata and one report-specific content object for Daily, 3-Day, Weekend, or Storm Report +- optional `currentConditions` prompt context from normalized + `/conditions/current` data when available - optional JSON file written by `briefing.Save` ## Boundaries @@ -36,6 +38,8 @@ The package receives configured units and timezone from the app layer. Daypart configuration is consumed by `internal/forecast` before briefing builders run. Configured `location` values are prompt context only; Weather API `sourceLocationId` and `sourceLocation` remain source provenance. +Current conditions are copied from the normalized `/conditions/current` bundle +source only; observation station and timestamp fields remain provenance. ## External Adapters Used diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index c29e33f..aac3bc8 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -18,8 +18,9 @@ Outputs: - `promptinput.Package` containing schema version, RunID, report metadata, briefing content, Recent Changes, and source warnings. Briefing content - includes configured location context, discussion key messages, and - short/long-term AFD narratives when the Weather API provides them. + includes configured location context, current conditions when available, + discussion key messages, and short/long-term AFD narratives when the Weather + API provides them. - optional JSON file written by `promptinput.Save` ## Boundaries diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index b851e80..5aa6062 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -6,7 +6,8 @@ This document describes Weather API ingestion into `forecast.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. +derivation and briefing builders. Briefing builders expose normalized current +conditions as prompt context when `/conditions/current` is available. ## Inputs And Outputs diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 27ce4d8..f7e956e 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -206,6 +206,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { 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) } + current := savedDataPackage.Briefing.CurrentConditions + if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 { + t.Fatalf("data package current conditions = %#v, want current conditions", current) + } if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") { t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data)) } @@ -887,7 +891,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server { case "/observations": _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) case "/conditions/current": - _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) + _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`)) case "/forecast/hourly": _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) case "/forecast/narrative": diff --git a/internal/briefing/daily.go b/internal/briefing/daily.go index 8e5816b..c8f5e4c 100644 --- a/internal/briefing/daily.go +++ b/internal/briefing/daily.go @@ -68,18 +68,16 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro if summary == nil { return Package{}, fmt.Errorf("daily forecast summary is required") } - pkg := Package{ - Metadata: BuildMetadata(ctx), - 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, - }, + 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 { diff --git a/internal/briefing/daily_test.go b/internal/briefing/daily_test.go index a54d3e8..8f62cbc 100644 --- a/internal/briefing/daily_test.go +++ b/internal/briefing/daily_test.go @@ -15,6 +15,19 @@ import ( func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { bundle := loadBundleFixture(t) + currentIsDay := true + currentTemp := 75.9 + currentFeelsLike := 76.1 + currentHumidity := 56.0 + currentWind := 10.7 + bundle.Current = &forecast.Current{ + ConditionText: "Partly cloudy", + IsDay: ¤tIsDay, + TemperatureF: ¤tTemp, + ApparentTemperatureF: ¤tFeelsLike, + RelativeHumidityPercent: ¤tHumidity, + WindSpeedMph: ¤tWind, + } bundle.Sources[0].DataSHA256 = "abc123" bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} location := mustLocation(t) @@ -55,6 +68,9 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { 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) } @@ -104,7 +120,9 @@ func TestDailyBriefingQuietWeather(t *testing.T) { Sources: []forecast.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: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}}, } summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) if err != nil { @@ -117,6 +135,12 @@ func TestDailyBriefingQuietWeather(t *testing.T) { 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)) } @@ -126,12 +150,12 @@ func TestDailyBriefingQuietWeather(t *testing.T) { 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) + data, err := json.Marshal(pkg.Metadata.Alerts) if err != nil { - t.Fatalf("marshal metadata: %v", err) + t.Fatalf("marshal alert metadata: %v", err) } if strings.Contains(string(data), `"missing"`) { - t.Fatalf("metadata includes missing for checked empty alerts:\n%s", string(data)) + t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data)) } } diff --git a/internal/briefing/package.go b/internal/briefing/package.go index a85db05..d34b21f 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -14,11 +14,12 @@ import ( const SchemaVersion = "weatherreporter.briefing.v1" type Package struct { - Metadata Metadata `json:"metadata"` - Daily *Daily `json:"daily,omitempty"` - ThreeDay *ThreeDay `json:"threeDay,omitempty"` - Weekend *Weekend `json:"weekend,omitempty"` - Storm *Storm `json:"storm,omitempty"` + 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 { @@ -46,6 +47,21 @@ 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"` @@ -94,6 +110,13 @@ 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 @@ -102,6 +125,58 @@ func copyLocation(location *LocationContext) *LocationContext { return &copied } +func currentConditions(bundle *forecast.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 + } + copied := *value + return &copied +} + +func copyFloat(value *float64) *float64 { + if value == nil { + return nil + } + copied := *value + 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) diff --git a/internal/briefing/storm.go b/internal/briefing/storm.go index 4827acf..4084238 100644 --- a/internal/briefing/storm.go +++ b/internal/briefing/storm.go @@ -56,10 +56,8 @@ func BuildStorm(ctx BuildContext) (Package, error) { Discussion: buildDiscussion(ctx.Bundle.Discussion), WeatherStory: buildWeatherStory(ctx.Bundle), } - pkg := Package{ - Metadata: BuildMetadata(ctx), - Storm: storm, - } + pkg := buildPackage(ctx) + pkg.Storm = storm setRelevantAlertCount(&pkg.Metadata, len(alerts)) return pkg, nil } diff --git a/internal/briefing/three_day.go b/internal/briefing/three_day.go index 3abf130..f7c5849 100644 --- a/internal/briefing/three_day.go +++ b/internal/briefing/three_day.go @@ -37,12 +37,10 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package 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), - }, + pkg := buildPackage(ctx) + pkg.ThreeDay = &ThreeDay{ + Discussion: buildDiscussion(summaries[0].Discussion), + WeatherStory: buildWeatherStory(ctx.Bundle), } for _, summary := range summaries { day := buildOutlookDay(summary) diff --git a/internal/briefing/weekend.go b/internal/briefing/weekend.go index 86e7563..90b0f26 100644 --- a/internal/briefing/weekend.go +++ b/internal/briefing/weekend.go @@ -31,12 +31,10 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, if len(summaries) == 0 { return Package{}, fmt.Errorf("weekend forecast summaries are required") } - pkg := Package{ - Metadata: BuildMetadata(ctx), - Weekend: &Weekend{ - Discussion: buildDiscussion(summaries[0].Discussion), - WeatherStory: buildWeatherStory(ctx.Bundle), - }, + 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)) diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index b6e81da..55466b1 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -34,6 +34,9 @@ func TestBuildDailyDataPackage(t *testing.T) { if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" { t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location) } + if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" { + t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions) + } if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 { t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items) } @@ -169,6 +172,9 @@ func validBriefingPackage() briefing.Package { End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC), }, }, + CurrentConditions: &briefing.CurrentConditionsContext{ + ConditionText: "Partly cloudy", + }, Daily: &briefing.Daily{ ForecastSummaryDate: "2026-05-29", },