Curate daypart prompt exports
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -325,6 +325,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDaypartSummariesModule,
|
||||
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user