From 1bfd8653332ff98e1a114532567167894b899718 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 15 Jun 2026 20:32:12 +0000 Subject: [PATCH] Curate current and hourly prompt exports --- internal/app/app_test.go | 23 ++++ internal/briefing/base_modules_test.go | 102 +++++++++++++++++ .../briefing/current_conditions_module.go | 36 ++++++ internal/briefing/hourly_forecast_module.go | 105 ++++++++++++++++++ internal/briefing/modules.go | 6 + internal/generatedtext/render_context_test.go | 6 + 6 files changed, 278 insertions(+) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e67075f..08f7d09 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -162,6 +162,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) { t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData)) } + for _, want := range []string{`"condition_text_lower"`, `"hour_label"`, `"text_description_lower"`, `"mention_precipitation"`} { + if !strings.Contains(string(snapshotData), want) { + t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData)) + } + } data, err := os.ReadFile(result.DataPackagePath) if err != nil { t.Fatalf("read data package: %v", err) @@ -222,6 +227,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if !ok || current["condition_text"] != "Clear" { t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"]) } + for _, omitted := range []string{"condition_text_lower", "wind_direction_text"} { + if _, ok := current[omitted]; ok { + t.Fatalf("data package current conditions contains helper field %q: %#v", omitted, current) + } + } narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any) if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") { t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"]) @@ -230,6 +240,19 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") { t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"]) } + periods, ok := hourly["periods"].([]any) + if !ok || len(periods) == 0 { + t.Fatalf("data package hourly periods = %#v, want prompt period rows", hourly["periods"]) + } + firstPeriod, ok := periods[0].(map[string]any) + if !ok { + t.Fatalf("data package hourly first period = %#v, want mapping", periods[0]) + } + for _, omitted := range []string{"hour_label", "text_description_lower", "mention_precipitation"} { + if _, ok := firstPeriod[omitted]; ok { + t.Fatalf("data package hourly period contains helper field %q: %#v", omitted, firstPeriod) + } + } story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any) if !ok || story["title"] != "Several Chances for Rain Through Monday" { t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"]) diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 81989f7..c0634ac 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -89,6 +89,48 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { } } +func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + richText := mustMarshalModuleJSON(t, output.Value) + for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} { + if !strings.Contains(richText, field) { + t.Fatalf("rich hourly json = %s, want helper field %s", richText, field) + } + } + + prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output) + if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 { + t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt) + } + period := prompt.Periods[0] + if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." { + t.Fatalf("hourly prompt period = %#v, want factual period fields", period) + } + if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { + t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period) + } + if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 { + t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period) + } + promptText := mustMarshalModuleJSON(t, output.DataPackageValue()) + for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { + if !strings.Contains(promptText, field) { + t.Fatalf("hourly prompt json = %s, want field %s", promptText, field) + } + } + for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} { + if strings.Contains(promptText, field) { + t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field) + } + } +} + func TestHourlyForecastPrecipMentionThreshold(t *testing.T) { periods := []weatherdata.ForecastPeriod{ {StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)}, @@ -260,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) { } } +func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + richText := mustMarshalModuleJSON(t, output.Value) + for _, field := range []string{"condition_text_lower", "wind_direction_text"} { + if !strings.Contains(richText, field) { + t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field) + } + } + + prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output) + if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 { + t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt) + } + if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 { + t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt) + } + if prompt.WindDirection != "S" { + t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection) + } + promptText := mustMarshalModuleJSON(t, output.DataPackageValue()) + for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} { + if !strings.Contains(promptText, field) { + t.Fatalf("current prompt json = %s, want field %s", promptText, field) + } + } + for _, field := range []string{"condition_text_lower", "wind_direction_text"} { + if strings.Contains(promptText, field) { + t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field) + } + } +} + func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() @@ -563,6 +643,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T { return value } +func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T { + t.Helper() + var value T + data, err := json.Marshal(output.DataPackageValue()) + if err != nil { + t.Fatalf("marshal module data package value: %v", err) + } + if err := json.Unmarshal(data, &value); err != nil { + t.Fatalf("decode module data package value: %v", err) + } + return value +} + +func mustMarshalModuleJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal module value: %v", err) + } + return string(data) +} + func mustParseModuleTime(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { diff --git a/internal/briefing/current_conditions_module.go b/internal/briefing/current_conditions_module.go index cef19d7..d138a96 100644 --- a/internal/briefing/current_conditions_module.go +++ b/internal/briefing/current_conditions_module.go @@ -23,6 +23,21 @@ type CurrentConditionsModule struct { WindDirectionText string `json:"wind_direction_text,omitempty"` } +type CurrentConditionsPromptExport struct { + ConditionText string `json:"condition_text,omitempty"` + IsDay *bool `json:"is_day,omitempty"` + TemperatureC *int `json:"temperature_c,omitempty"` + TemperatureF *int `json:"temperature_f,omitempty"` + ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"` + ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"` + DewpointC *int `json:"dewpoint_c,omitempty"` + DewpointF *int `json:"dewpoint_f,omitempty"` + RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"` + WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"` + WindSpeedMph *int `json:"wind_speed_mph,omitempty"` + WindDirection string `json:"wind_direction,omitempty"` +} + func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { current := ctx.Collected.Current if current == nil { @@ -50,6 +65,27 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil } +func exportCurrentConditionsPromptValue(value any) (any, error) { + rich, ok := value.(CurrentConditionsModule) + if !ok { + return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{}) + } + return CurrentConditionsPromptExport{ + ConditionText: rich.ConditionText, + IsDay: copyBool(rich.IsDay), + TemperatureC: copyInt(rich.TemperatureC), + TemperatureF: copyInt(rich.TemperatureF), + ApparentTemperatureC: copyInt(rich.ApparentTemperatureC), + ApparentTemperatureF: copyInt(rich.ApparentTemperatureF), + DewpointC: copyInt(rich.DewpointC), + DewpointF: copyInt(rich.DewpointF), + RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent), + WindSpeedKmh: copyInt(rich.WindSpeedKmh), + WindSpeedMph: copyInt(rich.WindSpeedMph), + WindDirection: rich.WindDirection, + }, nil +} + func (v CurrentConditionsModule) isEmpty() bool { return v.ConditionText == "" && v.ConditionTextLower == "" && diff --git a/internal/briefing/hourly_forecast_module.go b/internal/briefing/hourly_forecast_module.go index 83c986c..d43688b 100644 --- a/internal/briefing/hourly_forecast_module.go +++ b/internal/briefing/hourly_forecast_module.go @@ -20,6 +20,15 @@ type HourlyForecastModule struct { Periods []HourlyForecastPeriod `json:"periods,omitempty"` } +type HourlyForecastPromptExport struct { + Product string `json:"product,omitempty"` + IssuedAt time.Time `json:"issued_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + SourceLocation string `json:"source_location,omitempty"` + SourceLocationID string `json:"source_location_id,omitempty"` + Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"` +} + type HourlyForecastPeriod struct { HourLabel string `json:"hour_label,omitempty"` PeriodBegins string `json:"period_begins,omitempty"` @@ -59,6 +68,42 @@ type HourlyForecastPeriod struct { RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` } +type HourlyForecastPromptPeriod struct { + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + Name string `json:"name,omitempty"` + IsDay *bool `json:"is_day,omitempty"` + ConditionCode *int `json:"condition_code,omitempty"` + TextDescription string `json:"text_description,omitempty"` + TemperatureC *float64 `json:"temperature_c,omitempty"` + TemperatureF *float64 `json:"temperature_f,omitempty"` + TemperatureCMin *float64 `json:"temperature_c_min,omitempty"` + TemperatureFMin *float64 `json:"temperature_f_min,omitempty"` + TemperatureCMax *float64 `json:"temperature_c_max,omitempty"` + TemperatureFMax *float64 `json:"temperature_f_max,omitempty"` + DewpointC *float64 `json:"dewpoint_c,omitempty"` + DewpointF *float64 `json:"dewpoint_f,omitempty"` + WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` + WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` + WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"` + WindGustMph *float64 `json:"wind_gust_mph,omitempty"` + WindDirection string `json:"wind_direction,omitempty"` + BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"` + BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"` + VisibilityMeters *float64 `json:"visibility_meters,omitempty"` + VisibilityMiles *float64 `json:"visibility_miles,omitempty"` + ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"` + ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"` + CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"` + ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"` + PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"` + PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"` + SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"` + SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"` + UVIndex *float64 `json:"uv_index,omitempty"` + RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` +} + func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) { hourly := ctx.Collected.Hourly if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 { @@ -78,6 +123,66 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil } +func exportHourlyForecastPromptValue(value any) (any, error) { + rich, ok := value.(HourlyForecastModule) + if !ok { + return nil, unexpectedPromptExportValue(value, HourlyForecastModule{}) + } + return HourlyForecastPromptExport{ + Product: rich.Product, + IssuedAt: rich.IssuedAt, + UpdatedAt: copyTime(rich.UpdatedAt), + SourceLocation: rich.SourceLocation, + SourceLocationID: rich.SourceLocationID, + Periods: hourlyForecastPromptPeriods(rich.Periods), + }, nil +} + +func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod { + if len(periods) == 0 { + return nil + } + out := make([]HourlyForecastPromptPeriod, 0, len(periods)) + for _, period := range periods { + out = append(out, HourlyForecastPromptPeriod{ + PeriodBegins: period.PeriodBegins, + PeriodEnds: period.PeriodEnds, + Name: period.Name, + IsDay: copyBool(period.IsDay), + ConditionCode: copyInt(period.ConditionCode), + TextDescription: period.TextDescription, + TemperatureC: copyFloat(period.TemperatureC), + TemperatureF: copyFloat(period.TemperatureF), + TemperatureCMin: copyFloat(period.TemperatureCMin), + TemperatureFMin: copyFloat(period.TemperatureFMin), + TemperatureCMax: copyFloat(period.TemperatureCMax), + TemperatureFMax: copyFloat(period.TemperatureFMax), + DewpointC: copyFloat(period.DewpointC), + DewpointF: copyFloat(period.DewpointF), + WindSpeedKmh: copyFloat(period.WindSpeedKmh), + WindSpeedMph: copyFloat(period.WindSpeedMph), + WindGustKmh: copyFloat(period.WindGustKmh), + WindGustMph: copyFloat(period.WindGustMph), + WindDirection: period.WindDirection, + BarometricPressurePa: copyFloat(period.BarometricPressurePa), + BarometricPressureInHg: copyFloat(period.BarometricPressureInHg), + VisibilityMeters: copyFloat(period.VisibilityMeters), + VisibilityMiles: copyFloat(period.VisibilityMiles), + ApparentTemperatureC: copyFloat(period.ApparentTemperatureC), + ApparentTemperatureF: copyFloat(period.ApparentTemperatureF), + CloudCoverPercent: copyFloat(period.CloudCoverPercent), + ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent), + PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm), + PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn), + SnowfallDepthMM: copyFloat(period.SnowfallDepthMM), + SnowfallDepthIn: copyFloat(period.SnowfallDepthIn), + UVIndex: copyFloat(period.UVIndex), + RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent), + }) + } + return out +} + func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold) } diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 5a4a5ba..940d5f3 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -40,6 +40,10 @@ type ModuleRegistry struct { definitions map[module.ID]ModuleDefinition } +func unexpectedPromptExportValue(got any, want any) error { + return fmt.Errorf("value has type %T, want %T", got, want) +} + func DefaultModuleRegistry() (ModuleRegistry, error) { return NewModuleRegistry(defaultModuleDefinitions()) } @@ -281,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition { SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildCurrentConditionsModule, + PromptExporter: exportCurrentConditionsPromptValue, }, { ID: module.NarrativeForecast, @@ -301,6 +306,7 @@ func defaultModuleDefinitions() []ModuleDefinition { SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}, MissingData: module.MissingDataOmit, Builder: buildHourlyForecastModule, + PromptExporter: exportHourlyForecastPromptValue, }, { ID: module.DerivedDailySummary, diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 63a0462..d750242 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -44,12 +44,18 @@ func TestBuildHourlyRenderContext(t *testing.T) { if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 { t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions) } + if ctx.Modules.CurrentConditions.ConditionTextLower != "partly cloudy" || ctx.Modules.CurrentConditions.WindDirectionText != "south" { + t.Fatalf("Modules.CurrentConditions helpers = %#v, want rich template helper fields", ctx.Modules.CurrentConditions) + } if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 { t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast) } if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period) } + if period := ctx.Modules.HourlyForecast.Periods[1]; period.HourLabel != "10:00 AM" || period.TextDescriptionLower != "showers" || !period.MentionPrecipitation { + t.Fatalf("Modules.HourlyForecast.Periods[1] helpers = %#v, want rich template helper fields", period) + } if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 { t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming) }