From 92614313295b65d315a608a1b0d7f9b686b753a7 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 15 Jun 2026 20:38:56 +0000 Subject: [PATCH] Curate daypart prompt exports --- internal/app/app_test.go | 21 +++ .../derived_daypart_summaries_module.go | 74 +++++++++++ internal/briefing/derived_modules_test.go | 120 ++++++++++++++++++ internal/briefing/modules.go | 1 + internal/generatedtext/render_context_test.go | 18 +++ .../reporttemplate/reporttemplate_test.go | 78 ++++++++++++ 6 files changed, 312 insertions(+) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 08f7d09..921f827 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -167,6 +167,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData)) } } + for _, want := range []string{`"temperature_phrase_f"`, `"dominant_condition_lower"`, `"dominant_condition_display"`, `"max_pop_time_label"`} { + if !strings.Contains(string(snapshotData), want) { + t.Fatalf("module snapshot missing rich daypart helper field %q:\n%s", want, string(snapshotData)) + } + } data, err := os.ReadFile(result.DataPackagePath) if err != nil { t.Fatalf("read data package: %v", err) @@ -253,6 +258,22 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { t.Fatalf("data package hourly period contains helper field %q: %#v", omitted, firstPeriod) } } + dayparts, ok := savedDataPackage.Briefing.Values["derived_daypart_summaries"].(map[string]any) + if !ok { + t.Fatalf("data package daypart summaries = %#v, want daypart map", savedDataPackage.Briefing.Values["derived_daypart_summaries"]) + } + morning, ok := dayparts["morning"].(map[string]any) + if !ok { + t.Fatalf("data package morning daypart = %#v, want daypart map", dayparts["morning"]) + } + if morning["max_pop_time"] != "6:00 AM" { + t.Fatalf("data package morning max_pop_time = %#v, want friendly label", morning["max_pop_time"]) + } + for _, omitted := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} { + if _, ok := morning[omitted]; ok { + t.Fatalf("data package morning daypart contains helper field %q: %#v", omitted, morning) + } + } 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/derived_daypart_summaries_module.go b/internal/briefing/derived_daypart_summaries_module.go index 1e75b5f..224d0fd 100644 --- a/internal/briefing/derived_daypart_summaries_module.go +++ b/internal/briefing/derived_daypart_summaries_module.go @@ -44,6 +44,34 @@ type DerivedDaypartSummaryModule struct { RelevantAlertCount int `json:"relevant_alert_count,omitempty"` } +type DerivedDaypartSummaryPromptExport struct { + Date string `json:"date,omitempty"` + DisplayName string `json:"display_name,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + TempRangeF string `json:"temp_range_f,omitempty"` + ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + MentionPrecipitation bool `json:"mention_precipitation,omitempty"` + MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` + MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` + DominantCondition string `json:"dominant_condition,omitempty"` + TemperatureTrend string `json:"temperature_trend,omitempty"` + TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"` + TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"` + TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"` + TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"` + NotableConditions []string `json:"notable_conditions,omitempty"` + Snow bool `json:"snow,omitempty"` + Ice bool `json:"ice,omitempty"` + Fog bool `json:"fog,omitempty"` + Heat bool `json:"heat,omitempty"` + Cold bool `json:"cold,omitempty"` + Wind bool `json:"wind,omitempty"` + RelevantAlertCount int `json:"relevant_alert_count,omitempty"` +} + func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { if len(ctx.Derived.DaypartSummaries) == 0 { return nil, fmt.Errorf("daypart summary facts are required") @@ -57,6 +85,52 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil } +func exportDerivedDaypartSummariesPromptValue(value any) (any, error) { + rich, ok := value.(map[string]DerivedDaypartSummaryModule) + if !ok { + return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{}) + } + out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich)) + for key, daypart := range rich { + out[key] = derivedDaypartSummaryPromptValue(daypart) + } + return out, nil +} + +func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport { + maxPopTime := rich.MaxPopTime + if rich.MaxPopTimeLabel != "" { + maxPopTime = rich.MaxPopTimeLabel + } + return DerivedDaypartSummaryPromptExport{ + Date: rich.Date, + DisplayName: rich.DisplayName, + PeriodBegins: rich.PeriodBegins, + PeriodEnds: rich.PeriodEnds, + TempRangeF: rich.TempRangeF, + ApparentTempRangeF: rich.ApparentTempRangeF, + MaxPopPercent: copyInt(rich.MaxPopPercent), + MaxPopTime: maxPopTime, + MentionPrecipitation: rich.MentionPrecipitation, + MaxWindGustMph: copyInt(rich.MaxWindGustMph), + MaxWindGustTime: rich.MaxWindGustTime, + DominantCondition: rich.DominantCondition, + TemperatureTrend: rich.TemperatureTrend, + TemperatureStartPhraseF: rich.TemperatureStartPhraseF, + TemperatureEndPhraseF: rich.TemperatureEndPhraseF, + TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF, + TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF, + NotableConditions: append([]string(nil), rich.NotableConditions...), + Snow: rich.Snow, + Ice: rich.Ice, + Fog: rich.Fog, + Heat: rich.Heat, + Cold: rich.Cold, + Wind: rich.Wind, + RelevantAlertCount: rich.RelevantAlertCount, + } +} + func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { temperature := daypartTemperatureDisplay(daypart) value := DerivedDaypartSummaryModule{ diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index d244f80..7a89854 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -201,6 +201,126 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { } } +func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := derivedModuleContext(report.Daily) + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + richText := mustMarshalModuleJSON(t, output.Value) + for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} { + if !strings.Contains(richText, field) { + t.Fatalf("rich daypart json = %s, want helper field %s", richText, field) + } + } + + prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output) + morning, ok := prompt["morning"] + if !ok { + t.Fatalf("daypart prompt keys = %#v, want morning", prompt) + } + if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" { + t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning) + } + if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation { + t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning) + } + if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" { + t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning) + } + if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" { + t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions) + } + afternoon := prompt["afternoon"] + if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 { + t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon) + } + + promptText := mustMarshalModuleJSON(t, output.DataPackageValue()) + for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} { + if !strings.Contains(promptText, field) { + t.Fatalf("daypart prompt json = %s, want field %s", promptText, field) + } + } + for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} { + if strings.Contains(promptText, field) { + t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field) + } + } +} + +func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) { + tests := []struct { + name string + temps []float64 + wantTrend string + wantStart string + wantEnd string + wantPeak string + wantSteady string + }{ + { + name: "rising", + temps: []float64{58, 68}, + wantTrend: "rising", + wantStart: "upper 50s", + wantEnd: "upper 60s", + }, + { + name: "falling", + temps: []float64{65, 58}, + wantTrend: "falling", + wantStart: "mid 60s", + wantEnd: "upper 50s", + }, + { + name: "peaking", + temps: []float64{62, 78, 65}, + wantTrend: "peaking", + wantPeak: "upper 70s", + }, + { + name: "steady", + temps: []float64{77, 78}, + wantTrend: "steady", + wantSteady: "upper 70s", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago") + prompt := derivedDaypartSummaryPromptValue(rich) + if prompt.TemperatureTrend != test.wantTrend || + prompt.TemperatureStartPhraseF != test.wantStart || + prompt.TemperatureEndPhraseF != test.wantEnd || + prompt.TemperaturePeakPhraseF != test.wantPeak || + prompt.TemperatureSteadyPhraseF != test.wantSteady { + t.Fatalf("prompt temperature presentation = %#v", prompt) + } + }) + } +} + +func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) { + withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{ + MaxPopTime: "6 AM", + MaxPopTimeLabel: "6:00 AM", + }) + if withLabel.MaxPopTime != "6:00 AM" { + t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime) + } + + withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{ + MaxPopTime: "6 AM", + }) + if withoutLabel.MaxPopTime != "6 AM" { + t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime) + } +} + func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) { tests := []struct { name string diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index 940d5f3..7e551d4 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -325,6 +325,7 @@ func defaultModuleDefinitions() []ModuleDefinition { SupportedReports: daypartReports, MissingData: module.MissingDataError, Builder: buildDerivedDaypartSummariesModule, + PromptExporter: exportDerivedDaypartSummariesPromptValue, }, { ID: module.PrecipTiming, diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index d750242..3ceac29 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -184,6 +184,12 @@ func TestBuildTodayRenderContext(t *testing.T) { if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" { t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts) } + if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" { + t.Fatalf("today rich morning daypart helpers = %#v, want render-context helper fields", morning) + } + if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" { + t.Fatalf("today rich afternoon daypart = %#v, want max pop time label helper", afternoon) + } if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 { t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming) } @@ -311,6 +317,12 @@ func TestBuildDailyRenderContext(t *testing.T) { if len(ctx.Modules.Dayparts) != 3 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" || ctx.Modules.Dayparts[2].Key != "evening" { t.Fatalf("Modules.Dayparts = %#v, want derived order followed by remaining keys", ctx.Modules.Dayparts) } + if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" { + t.Fatalf("daily rich morning daypart helpers = %#v, want render-context helper fields", morning) + } + if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" { + t.Fatalf("daily rich afternoon daypart = %#v, want max pop time label helper", afternoon) + } if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 { t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming) } @@ -434,6 +446,12 @@ func TestBuildTomorrowRenderContext(t *testing.T) { if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" { t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts) } + if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" { + t.Fatalf("tomorrow rich morning daypart helpers = %#v, want render-context helper fields", morning) + } + if afternoon := (*ctx.Modules.DerivedDaypartSummaries)["afternoon"]; afternoon.MaxPopTimeLabel != "3:00 PM" { + t.Fatalf("tomorrow rich afternoon daypart = %#v, want max pop time label helper", afternoon) + } if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 { t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming) } diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index b656955..a60fe17 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -578,6 +578,84 @@ func TestRenderToday(t *testing.T) { } } +func TestRenderDaypartTemplatesUseRichHelperFields(t *testing.T) { + tests := []struct { + name string + template string + context any + want []string + }{ + { + name: "daily", + template: "daily", + context: testDailyRenderContext{ + Report: testDailyReportContext{Title: "Daily", ForecastDateLabel: "Monday, June 15, 2026"}, + GeneratedText: testDailyGeneratedText{Summary: "Daily summary."}, + Modules: testDailyModules{Dayparts: []testDailyDaypart{ + {Key: "morning", Summary: testDaypartSummary{ + DisplayName: "Morning", + DominantConditionDisplay: "Showers", + TemperaturePhraseF: "upper 60s", + }}, + }}, + }, + want: []string{"- **Morning:** Showers, with temperatures in the upper 60s."}, + }, + { + name: "today", + template: "today", + context: testTodayRenderContext{ + Report: testTodayReportContext{Title: "Today", ForecastDateLabel: "Monday, June 15, 2026"}, + GeneratedText: testTomorrowGeneratedText{Summary: "Today summary."}, + Modules: testTodayModules{Dayparts: []testTomorrowDaypart{ + {Key: "afternoon", Summary: testDaypartSummary{ + DisplayName: "Afternoon", + DominantConditionDisplay: "Storms", + TemperaturePeakPhraseF: "low 80s", + TemperatureTrend: "peaking", + MaxPopPercent: intPtr(70), + MentionPrecipitation: true, + }}, + }}, + }, + want: []string{"- **Afternoon:** Storms, with temperatures peaking in the low 80s. Chance of precipitation is 70%."}, + }, + { + name: "tomorrow", + template: "tomorrow", + context: testTomorrowRenderContext{ + Report: testTomorrowReportContext{Title: "Tomorrow", ForecastDateLabel: "Tuesday, June 16, 2026"}, + GeneratedText: testTomorrowGeneratedText{Summary: "Tomorrow summary."}, + Modules: testTomorrowModules{Dayparts: []testTomorrowDaypart{ + {Key: "morning", Summary: testDaypartSummary{ + DisplayName: "Morning", + DominantConditionDisplay: "Clear", + TemperatureStartPhraseF: "upper 50s", + TemperatureEndPhraseF: "upper 60s", + TemperatureTrend: "rising", + }}, + }}, + }, + want: []string{"- **Morning:** Clear, with temperatures rising from the upper 50s to the upper 60s."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rendered, err := Render(tt.template, tt.context) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, want := range tt.want { + if !strings.Contains(text, want) { + t.Fatalf("rendered template missing %q:\n%s", want, text) + } + } + }) + } +} + func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) { rendered, err := Render("tomorrow", testTomorrowRenderContext{ Report: testTomorrowReportContext{