From 74e32eb18e1094d5ec547804438eaa54555f36c5 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 14 Jun 2026 12:54:32 -0500 Subject: [PATCH] Simplify and rationalize the hourly report template --- docs/internal/generatedtext.md | 11 +- docs/internal/reporttemplate.md | 9 +- docs/templates.md | 24 ++-- internal/app/app_test.go | 20 ++-- internal/briefing/base_modules_test.go | 35 +++++- .../briefing/current_conditions_module.go | 14 ++- internal/briefing/derived_modules_test.go | 6 +- internal/briefing/hourly_forecast_module.go | 17 +++ internal/briefing/module_format_helpers.go | 38 ++++++ .../briefing/module_format_helpers_test.go | 22 ++++ internal/briefing/precip_timing_module.go | 16 ++- internal/cli/root_test.go | 8 +- internal/generatedtext/hourly.go | 19 ++- internal/generatedtext/hourly_test.go | 42 +++++-- internal/generatedtext/render_context.go | 6 +- internal/generatedtext/render_context_test.go | 43 ++++--- .../prompts/hourly.generated_text.md | 26 ++-- .../reporttemplate/reporttemplate_test.go | 113 +++++++++++++----- .../schemas/hourly.generated_text.schema.json | 7 +- .../reporttemplate/templates/hourly.md.tmpl | 94 ++++----------- internal/state/filesystem_test.go | 4 +- 21 files changed, 363 insertions(+), 211 deletions(-) diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md index 49637fb..54b7f3c 100644 --- a/docs/internal/generatedtext.md +++ b/docs/internal/generatedtext.md @@ -29,14 +29,15 @@ The hourly generated text JSON accepts: ```json { "summary": "string", - "timing": "string", - "impacts": "string", + "forecast_discussion": "string", + "precipitation_timing": "string", "confidence": "string" } ``` -`summary`, `timing`, and `impacts` are required after trimming whitespace. -`confidence` is optional and omitted from normalized JSON when blank. +`summary` and `forecast_discussion` are required after trimming whitespace. +`precipitation_timing` and `confidence` are optional and omitted from normalized +JSON when blank. ## Boundaries @@ -54,7 +55,7 @@ The hourly generated text JSON accepts: - Malformed generated-text JSON fails with decode context. - Unknown generated-text JSON fields fail during decoding. - Empty required hourly fields fail after trimming whitespace. -- Missing required render-context stanzas fail with the stanza name. +- Missing optional render-context stanzas become nil module pointers. - Invalid render metadata, including missing timezone, missing generated time, or invalid valid period, fails before template rendering. diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md index 96b0073..b4826fc 100644 --- a/docs/internal/reporttemplate.md +++ b/docs/internal/reporttemplate.md @@ -61,12 +61,11 @@ The hourly GeneratedText schema describes the structured prose Scriptorium is expected to write for the prompt. It requires: - `summary` -- `timing` -- `impacts` +- `forecast_discussion` -It allows optional `confidence` and rejects additional properties. Weather truth -remains in module outputs; GeneratedText is limited to prose slots consumed by -the template. +It allows optional `precipitation_timing` and `confidence`, and rejects +additional properties. Weather truth remains in module outputs; GeneratedText is +limited to prose slots consumed by the template. ## Failure Behavior diff --git a/docs/templates.md b/docs/templates.md index 1f20ae3..c278660 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -63,22 +63,18 @@ weatherreporter, and then inserted into the render context. | Variable | Type | Description | | --- | --- | --- | | `.GeneratedText.Summary` | string | Required short prose summary. | -| `.GeneratedText.Timing` | string | Required prose about timing of notable weather changes or hazards. | -| `.GeneratedText.Impacts` | string | Required prose about practical near-term impacts. | -| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM. | +| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. | +| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. | +| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. | Example: ```gotemplate -## Summary - {{ .GeneratedText.Summary }} -{{ with .GeneratedText.Confidence }} -## Confidence +## Forecast Discussion -{{ . }} -{{ end }} +{{ .GeneratedText.ForecastDiscussion }} ``` ## Modules @@ -106,10 +102,12 @@ Common fields: | Variable | Type | Description | | --- | --- | --- | | `.Modules.CurrentConditions.ConditionText` | string | Current condition text. | +| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. | | `.Modules.CurrentConditions.TemperatureF` | *float64 | Current temperature. | | `.Modules.CurrentConditions.ApparentTemperatureF` | *float64 | Apparent temperature. | | `.Modules.CurrentConditions.RelativeHumidityPercent` | *float64 | Relative humidity. | | `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. | +| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. | | `.Modules.CurrentConditions.WindSpeedMph` | *float64 | Wind speed. | Example: @@ -129,12 +127,15 @@ Common period fields: | Variable | Type | Description | | --- | --- | --- | | `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. | +| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. | | `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. | | `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. | | `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. | | `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. | +| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. | | `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. | | `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. | +| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. | | `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. | | `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. | | `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. | @@ -143,7 +144,7 @@ Example: ```gotemplate {{ with .Modules.HourlyForecast }}{{ range .Periods }} -- {{ .PeriodBegins }}: {{ .TextDescription }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ProbabilityOfPrecipitationPercent }}; {{ . }}% precipitation{{ end }} +- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }} {{ else }} - No hourly forecast rows available. {{ end }}{{ end }} @@ -160,9 +161,12 @@ Common fields: | `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. | | `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. | | `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. | +| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. | | `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. | +| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. | | `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. | | `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. | +| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. | | `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. | ### Alert Digest diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0eb5067..58c588c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -386,8 +386,8 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { }, structuredRunBody: `{ "summary": " Storm chances increase through late morning. ", - "timing": "The main window is 10 AM to noon.", - "impacts": "Brief downpours may slow travel.", + "forecast_discussion": "A front will keep the region unsettled.", + "precipitation_timing": "A cold front is moving into the region.", "confidence": "Medium" }`, } @@ -443,7 +443,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { if err != nil { t.Fatalf("read validated generated text: %v", err) } - if string(normalized) != `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}` { + if string(normalized) != `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` { t.Fatalf("validated generated text = %s, want normalized JSON", normalized) } renderContext, err := os.ReadFile(result.RenderContextPath) @@ -473,10 +473,11 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { for _, want := range []string{ "# Hourly Report", "Storm chances increase through late morning.", - "The main window is 10 AM to noon.", - "Flood Watch: Flooding possible (Moderate)", - "Short-term AFD narrative for hourly report.", - "Hourly Storm Chances - Scattered showers and thunderstorms are possible.", + "## Active Alerts", + "- **Flood Watch**: Flooding possible", + "## Precipitation Timing", + "A cold front is moving into the region.", + "A front will keep the region unsettled.", } { if !strings.Contains(reportText, want) { t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText) @@ -734,8 +735,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) { structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, structuredRunBody: `{ "summary": "Storm chances increase through late morning.", - "timing": "The main window is 10 AM to noon.", - "impacts": "Brief downpours may slow travel.", + "forecast_discussion": "A front will keep the region unsettled.", "details": "not allowed" }`, } @@ -2058,7 +2058,7 @@ func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report. } func validHourlyGeneratedTextJSON() string { - return `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}` + return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` } func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult { diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 0b8cd8c..1ac7c40 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -59,7 +59,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value) } period := value.Periods[0] - if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { + if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period) } if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" { @@ -68,12 +68,15 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) } + if !period.MentionPrecipitation { + t.Fatalf("MentionPrecipitation = false, want true for default threshold") + } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal hourly forecast: %v", err) } jsonText := string(data) - for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { + for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} { if !strings.Contains(jsonText, field) { t.Fatalf("hourly json = %s, want field %s", jsonText, field) } @@ -86,6 +89,27 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) { } } +func TestHourlyForecastPrecipMentionThreshold(t *testing.T) { + periods := []weatherdata.ForecastPeriod{ + {StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)}, + {StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)}, + {StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")}, + } + value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold) + if len(value) != 3 { + t.Fatalf("periods length = %d, want 3", len(value)) + } + if value[0].MentionPrecipitation { + t.Fatalf("19%% MentionPrecipitation = true, want false") + } + if !value[1].MentionPrecipitation { + t.Fatalf("20%% MentionPrecipitation = false, want true") + } + if value[2].MentionPrecipitation { + t.Fatalf("nil MentionPrecipitation = true, want false") + } +} + func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() @@ -209,18 +233,21 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[CurrentConditionsModule](t, output) - if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 { + if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 { t.Fatalf("CurrentConditions = %#v, want current condition facts", value) } if value.WindDirection != "S" { t.Fatalf("WindDirection = %q, want S", value.WindDirection) } + if value.WindDirectionText != "south" { + t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText) + } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal current conditions: %v", err) } jsonText := string(data) - for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} { + for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} { if !strings.Contains(jsonText, field) { t.Fatalf("current json = %s, want field %s", jsonText, field) } diff --git a/internal/briefing/current_conditions_module.go b/internal/briefing/current_conditions_module.go index e276344..edb91d4 100644 --- a/internal/briefing/current_conditions_module.go +++ b/internal/briefing/current_conditions_module.go @@ -1,9 +1,14 @@ package briefing -import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" +import ( + "strings" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" +) type CurrentConditionsModule struct { ConditionText string `json:"condition_text,omitempty"` + ConditionTextLower string `json:"condition_text_lower,omitempty"` IsDay *bool `json:"is_day,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureF *float64 `json:"temperature_f,omitempty"` @@ -15,6 +20,7 @@ type CurrentConditionsModule struct { WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` WindDirection string `json:"wind_direction,omitempty"` + WindDirectionText string `json:"wind_direction_text,omitempty"` } func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { @@ -24,6 +30,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err } value := CurrentConditionsModule{ ConditionText: current.ConditionText, + ConditionTextLower: strings.ToLower(current.ConditionText), IsDay: copyBool(current.IsDay), TemperatureC: copyFloat(current.TemperatureC), TemperatureF: copyFloat(current.TemperatureF), @@ -35,6 +42,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err WindSpeedKmh: copyFloat(current.WindSpeedKmh), WindSpeedMph: copyFloat(current.WindSpeedMph), WindDirection: windDirectionLabel(current.WindDirectionDegrees), + WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees), } if value.isEmpty() { return nil, nil @@ -44,6 +52,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err func (v CurrentConditionsModule) isEmpty() bool { return v.ConditionText == "" && + v.ConditionTextLower == "" && v.IsDay == nil && v.TemperatureC == nil && v.TemperatureF == nil && @@ -54,5 +63,6 @@ func (v CurrentConditionsModule) isEmpty() bool { v.RelativeHumidityPercent == nil && v.WindSpeedKmh == nil && v.WindSpeedMph == nil && - v.WindDirection == "" + v.WindDirection == "" && + v.WindDirectionText == "" } diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index 2f6b0e1..a0ea97b 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -102,17 +102,17 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { if len(rainy.PrecipitationWindows) != 2 { t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) } - if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { + if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" { t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) } - if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { + if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" { t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal precip timing: %v", err) } - if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { + if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") { t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) } if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) { diff --git a/internal/briefing/hourly_forecast_module.go b/internal/briefing/hourly_forecast_module.go index 247a206..83c986c 100644 --- a/internal/briefing/hourly_forecast_module.go +++ b/internal/briefing/hourly_forecast_module.go @@ -1,6 +1,7 @@ package briefing import ( + "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" @@ -8,6 +9,8 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) +const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20 + type HourlyForecastModule struct { Product string `json:"product,omitempty"` IssuedAt time.Time `json:"issued_at,omitempty"` @@ -18,12 +21,14 @@ type HourlyForecastModule struct { } type HourlyForecastPeriod struct { + HourLabel string `json:"hour_label,omitempty"` 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"` + TextDescriptionLower string `json:"text_description_lower,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureF *float64 `json:"temperature_f,omitempty"` TemperatureCMin *float64 `json:"temperature_c_min,omitempty"` @@ -45,6 +50,7 @@ type HourlyForecastPeriod struct { ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"` CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"` ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"` + MentionPrecipitation bool `json:"mention_precipitation,omitempty"` PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"` PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"` SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"` @@ -73,16 +79,22 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) } func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { + return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold) +} + +func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod { out := make([]HourlyForecastPeriod, 0, len(periods)) for _, period := range periods { validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime} out = append(out, HourlyForecastPeriod{ + HourLabel: hourMinuteLabel(period.StartTime, timezone), PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone), PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone), Name: period.Name, IsDay: copyBool(period.IsDay), ConditionCode: copyInt(period.ConditionCode), TextDescription: period.TextDescription, + TextDescriptionLower: strings.ToLower(period.TextDescription), TemperatureC: copyFloat(period.TemperatureC), TemperatureF: copyFloat(period.TemperatureF), TemperatureCMin: copyFloat(period.TemperatureCMin), @@ -104,6 +116,7 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string ApparentTemperatureF: copyFloat(period.ApparentTemperatureF), CloudCoverPercent: copyFloat(period.CloudCoverPercent), ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent), + MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold), PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm), PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn), SnowfallDepthMM: copyFloat(period.SnowfallDepthMM), @@ -115,6 +128,10 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string return out } +func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool { + return probability != nil && *probability >= threshold +} + func (v HourlyForecastModule) isEmpty() bool { return v.Product == "" && v.IssuedAt.IsZero() && diff --git a/internal/briefing/module_format_helpers.go b/internal/briefing/module_format_helpers.go index f61b48f..83807c1 100644 --- a/internal/briefing/module_format_helpers.go +++ b/internal/briefing/module_format_helpers.go @@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string { return labels[sector] } +func windDirectionTextLabel(degrees *float64) string { + if degrees == nil { + return "" + } + labels := []string{ + "north", + "north-northeast", + "northeast", + "east-northeast", + "east", + "east-southeast", + "southeast", + "south-southeast", + "south", + "south-southwest", + "southwest", + "west-southwest", + "west", + "west-northwest", + "northwest", + "north-northwest", + } + normalized := math.Mod(*degrees, 360) + if normalized < 0 { + normalized += 360 + } + sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels) + return labels[sector] +} + func timedClockLabel(value *forecast.TimedValue, timezone string) string { if value == nil { return "" @@ -126,3 +156,11 @@ func clockLabel(value time.Time, timezone string) string { } return label } + +func hourMinuteLabel(value time.Time, timezone string) string { + location, err := timeutil.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + return value.In(location).Format("3:04 PM") +} diff --git a/internal/briefing/module_format_helpers_test.go b/internal/briefing/module_format_helpers_test.go index a1f7e7f..ac44b5e 100644 --- a/internal/briefing/module_format_helpers_test.go +++ b/internal/briefing/module_format_helpers_test.go @@ -27,3 +27,25 @@ func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) { }) } } + +func TestWindDirectionTextLabelUsesLowercaseCompassText(t *testing.T) { + tests := []struct { + name string + degrees *float64 + want string + }{ + {name: "nil", degrees: nil, want: ""}, + {name: "north", degrees: floatPtr(0), want: "north"}, + {name: "north northeast", degrees: floatPtr(11.25), want: "north-northeast"}, + {name: "northwest", degrees: floatPtr(315), want: "northwest"}, + {name: "negative normalizes", degrees: floatPtr(-45), want: "northwest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := windDirectionTextLabel(tt.degrees); got != tt.want { + t.Fatalf("windDirectionTextLabel(%v) = %q, want %q", tt.degrees, got, tt.want) + } + }) + } +} diff --git a/internal/briefing/precip_timing_module.go b/internal/briefing/precip_timing_module.go index 1d3540a..c5c2be8 100644 --- a/internal/briefing/precip_timing_module.go +++ b/internal/briefing/precip_timing_module.go @@ -14,10 +14,13 @@ type PrecipTimingModule struct { } type PrecipitationWindowModule struct { - PeriodBegins string `json:"period_begins"` - PeriodEnds string `json:"period_ends,omitempty"` - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopTime string `json:"max_pop_time,omitempty"` + PeriodBegins string `json:"period_begins"` + PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"` } func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { @@ -36,13 +39,16 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi } for _, window := range timing.PrecipitationWindows { item := PrecipitationWindowModule{ - PeriodBegins: friendlyDateTimeLabel(window.Start, timezone), + PeriodBegins: friendlyDateTimeLabel(window.Start, timezone), + PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone), } if window.End != nil { item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone) + item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone) } item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) + item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone) value.PrecipitationWindows = append(value.PrecipitationWindows, item) } return value diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index e30c1af..d6f1885 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -715,8 +715,8 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { for _, want := range []string{ "# Hourly Report", "Storm chances increase through late morning.", - "The main window is 10 AM to noon.", - "Brief downpours may slow travel.", + "A cold front is moving into the region.", + "A front will keep the region unsettled.", } { if !strings.Contains(string(report), want) { t.Fatalf("report output missing %q:\n%s", want, string(report)) @@ -1221,8 +1221,8 @@ if [ "$1" = "run" ]; then cat > "$out" <<'JSON' { "summary": " Storm chances increase through late morning. ", - "timing": "The main window is 10 AM to noon.", - "impacts": "Brief downpours may slow travel.", + "forecast_discussion": "A front will keep the region unsettled.", + "precipitation_timing": "A cold front is moving into the region.", "confidence": "Medium" } JSON diff --git a/internal/generatedtext/hourly.go b/internal/generatedtext/hourly.go index f7aa8a3..cd02275 100644 --- a/internal/generatedtext/hourly.go +++ b/internal/generatedtext/hourly.go @@ -10,10 +10,10 @@ import ( ) type Hourly struct { - Summary string `json:"summary"` - Timing string `json:"timing"` - Impacts string `json:"impacts"` - Confidence string `json:"confidence,omitempty"` + Summary string `json:"summary"` + ForecastDiscussion string `json:"forecast_discussion"` + PrecipitationTiming string `json:"precipitation_timing,omitempty"` + Confidence string `json:"confidence,omitempty"` } func ValidateHourly(data []byte) (Hourly, []byte, error) { @@ -34,17 +34,14 @@ func ValidateHourly(data []byte) (Hourly, []byte, error) { } value.Summary = strings.TrimSpace(value.Summary) - value.Timing = strings.TrimSpace(value.Timing) - value.Impacts = strings.TrimSpace(value.Impacts) + value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion) + value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) value.Confidence = strings.TrimSpace(value.Confidence) if value.Summary == "" { return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required") } - if value.Timing == "" { - return Hourly{}, nil, fmt.Errorf("hourly generated text timing is required") - } - if value.Impacts == "" { - return Hourly{}, nil, fmt.Errorf("hourly generated text impacts is required") + if value.ForecastDiscussion == "" { + return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required") } normalized, err := json.Marshal(value) diff --git a/internal/generatedtext/hourly_test.go b/internal/generatedtext/hourly_test.go index 2aae71b..d98a336 100644 --- a/internal/generatedtext/hourly_test.go +++ b/internal/generatedtext/hourly_test.go @@ -7,10 +7,10 @@ import ( func TestValidateHourlyNormalizesJSON(t *testing.T) { value, normalized, err := ValidateHourly([]byte(`{ - "timing": " main window late morning ", "summary": " Storm chances increase. ", - "confidence": " Medium ", - "impacts": " Brief downpours. " + "forecast_discussion": " A front will keep the region unsettled. ", + "precipitation_timing": " Showers are most likely early this afternoon. ", + "confidence": " Medium " }`)) if err != nil { t.Fatalf("ValidateHourly() error = %v", err) @@ -18,7 +18,13 @@ func TestValidateHourlyNormalizesJSON(t *testing.T) { if value.Summary != "Storm chances increase." { t.Fatalf("Summary = %q, want trimmed summary", value.Summary) } - want := `{"summary":"Storm chances increase.","timing":"main window late morning","impacts":"Brief downpours.","confidence":"Medium"}` + if value.ForecastDiscussion != "A front will keep the region unsettled." { + t.Fatalf("ForecastDiscussion = %q, want trimmed discussion", value.ForecastDiscussion) + } + if value.PrecipitationTiming != "Showers are most likely early this afternoon." { + t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) + } + want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"Showers are most likely early this afternoon.","confidence":"Medium"}` if string(normalized) != want { t.Fatalf("normalized = %s, want %s", normalized, want) } @@ -27,14 +33,14 @@ func TestValidateHourlyNormalizesJSON(t *testing.T) { func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) { _, normalized, err := ValidateHourly([]byte(`{ "summary": "Storm chances increase.", - "timing": "Late morning.", - "impacts": "Brief downpours.", + "forecast_discussion": "A front will keep the region unsettled.", + "precipitation_timing": " ", "confidence": " " }`)) if err != nil { t.Fatalf("ValidateHourly() error = %v", err) } - want := `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}` + want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}` if string(normalized) != want { t.Fatalf("normalized = %s, want %s", normalized, want) } @@ -53,22 +59,32 @@ func TestValidateHourlyRejectsInvalidInput(t *testing.T) { }, { name: "unknown field", - in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours.","extra":"value"}`, + in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","extra":"value"}`, want: `unknown field "extra"`, }, { name: "missing summary", - in: `{"timing":"Late morning.","impacts":"Brief downpours."}`, + in: `{"forecast_discussion":"A front will keep the region unsettled."}`, want: "summary is required", }, { - name: "blank timing", - in: `{"summary":"Storm chances increase.","timing":" ","impacts":"Brief downpours."}`, - want: "timing is required", + name: "blank forecast discussion", + in: `{"summary":"Storm chances increase.","forecast_discussion":" "}`, + want: "forecast discussion is required", + }, + { + name: "old timing field rejected", + in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","timing":"Late morning."}`, + want: `unknown field "timing"`, + }, + { + name: "old impacts field rejected", + in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","impacts":"Brief downpours."}`, + want: `unknown field "impacts"`, }, { name: "multiple values", - in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."} {}`, + in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."} {}`, want: "multiple JSON values", }, } diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index 1ee8f63..f33b08b 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -60,7 +60,7 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh Title: "Hourly Report", LocationName: locationName(metadata), GeneratedAt: metadata.GeneratedAt, - GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location), + GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), ValidPeriod: metadata.ValidPeriod, ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location), Timezone: metadata.Timezone, @@ -159,3 +159,7 @@ func periodLabel(period timeutil.Period, location *time.Location) string { func timeLabel(value time.Time, location *time.Location) string { return value.In(location).Format("2006-01-02 at 3:04 PM") } + +func generatedAtLabel(value time.Time, location *time.Location) string { + return value.In(location).Format("Monday, January 2, 2006 at 3:04 PM") +} diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index aabefd0..e9564ab 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -18,10 +18,10 @@ func TestBuildHourlyRenderContext(t *testing.T) { metadata := testMetadata() snapshot := testSnapshot(t) generated := Hourly{ - Summary: "Storm chances increase through late morning.", - Timing: "The main window is 10 AM to noon.", - Impacts: "Brief downpours may slow travel.", - Confidence: "Medium confidence in timing.", + Summary: "Storm chances increase through late morning.", + ForecastDiscussion: "A front will keep the region unsettled.", + PrecipitationTiming: "A cold front is moving into the region.", + Confidence: "Medium confidence in timing.", } collected := testCollected() derived := testDerived() @@ -38,6 +38,9 @@ func TestBuildHourlyRenderContext(t *testing.T) { if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" { t.Fatalf("Report.ValidPeriodLabel = %q, want friendly period", ctx.Report.ValidPeriodLabel) } + if ctx.Report.GeneratedAtLabel != "Friday, May 29, 2026 at 8:30 AM" { + t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) + } 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) } @@ -79,10 +82,12 @@ func TestBuildHourlyRenderContext(t *testing.T) { text := string(rendered) for _, want := range []string{ "# Hourly Report", + "**Updated:** Friday, May 29, 2026 at 8:30 AM", "Storm chances increase through late morning.", - "- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph", - "- Flood Watch: Flood Watch until early afternoon (Moderate)", - "Morning storms - Morning storms remain the main story.", + "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", + "- **Flood Watch**: Flood Watch until early afternoon", + "A cold front is moving into the region.", + "A front will keep the region unsettled.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) @@ -98,9 +103,8 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) { t.Fatalf("NewSnapshot() error = %v", err) } ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ - Summary: "Storm chances increase.", - Timing: "Late morning.", - Impacts: "Brief downpours.", + Summary: "Storm chances increase.", + ForecastDiscussion: "A front will keep the region unsettled.", }, testCollected(), facts.DerivedFacts{}) if err != nil { t.Fatalf("BuildHourlyRenderContext() error = %v", err) @@ -146,11 +150,13 @@ func testSnapshot(t *testing.T) module.Snapshot { StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{ ConditionText: "Partly cloudy", + ConditionTextLower: "partly cloudy", TemperatureF: floatPtr(74), ApparentTemperatureF: floatPtr(76), RelativeHumidityPercent: floatPtr(71), WindSpeedMph: floatPtr(8), WindDirection: "S", + WindDirectionText: "south", }, }, { @@ -159,18 +165,24 @@ func testSnapshot(t *testing.T) module.Snapshot { Value: briefing.HourlyForecastModule{ Periods: []briefing.HourlyForecastPeriod{ { + HourLabel: "9:00 AM", PeriodBegins: "2026-05-29 at 9:00 AM", TextDescription: "Cloudy", + TextDescriptionLower: "cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), + MentionPrecipitation: true, WindSpeedMph: floatPtr(8), WindDirection: "S", }, { + HourLabel: "10:00 AM", PeriodBegins: "2026-05-29 at 10:00 AM", TextDescription: "Showers", + TextDescriptionLower: "showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), + MentionPrecipitation: true, WindSpeedMph: floatPtr(10), WindGustMph: floatPtr(18), WindDirection: "S", @@ -187,10 +199,13 @@ func testSnapshot(t *testing.T) module.Snapshot { ProbabilityThreshold: 50, PrecipitationWindows: []briefing.PrecipitationWindowModule{ { - PeriodBegins: "2026-05-29 at 10:00 AM", - PeriodEnds: "2026-05-29 at 12:00 PM", - MaxPopPercent: intPtr(70), - MaxPopTime: "10 AM", + PeriodBegins: "2026-05-29 at 10:00 AM", + PeriodBeginsHourLabel: "10:00 AM", + PeriodEnds: "2026-05-29 at 12:00 PM", + PeriodEndsHourLabel: "12:00 PM", + MaxPopPercent: intPtr(70), + MaxPopTime: "10 AM", + MaxPopHourLabel: "10:00 AM", }, }, ThunderMentioned: true, diff --git a/internal/reporttemplate/prompts/hourly.generated_text.md b/internal/reporttemplate/prompts/hourly.generated_text.md index 9d2b548..cac7381 100644 --- a/internal/reporttemplate/prompts/hourly.generated_text.md +++ b/internal/reporttemplate/prompts/hourly.generated_text.md @@ -1,4 +1,4 @@ -You are writing structured text slots for a short-term weather report. +You are writing structured prose slots for a short-term hourly weather report. The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured @@ -14,27 +14,31 @@ Write for a general local audience. Be clear, practical, and concise. Return these fields: -- `summary`: 1-2 sentences summarizing the main weather story for the valid - period. -- `timing`: 1-3 sentences explaining when the most important changes or hazards - are expected during the valid period. -- `impacts`: 1-3 sentences explaining practical impacts for people planning - travel, outdoor activity, errands, commuting, or similar near-term decisions. +- `summary`: required. 1-2 sentences summarizing the main weather story for the + valid period. +- `forecast_discussion`: required. 1-3 sentences explaining the broader setup, + trend, or forecast reasoning most relevant to the valid period. +- `precipitation_timing`: optional. Include only when the deterministic + `precip_timing` module contains precipitation windows. Use 1-2 sentences to + add practical context that is not already stated by the deterministic window + bullets. - `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast. Guidance: -- Prefer location-applicable risk products, active alerts, and overlapping SPC +- Do not repeat deterministic current conditions, alert bullets, hourly forecast + bullets, or precipitation-window bullets verbatim. +- Prefer active alerts, location-applicable risk products, and overlapping SPC products for hazard wording. - Use the hourly forecast and precipitation timing modules for timing details. - Use current conditions only for immediate context; do not let them override the forecast. -- Use AFD key messages and short-term discussion for context, but keep regional - or broad discussion tied back to the configured location and valid period. +- Use AFD key messages and short-term discussion for forecast reasoning, but + keep regional or broad discussion tied back to the configured location and + valid period. - Mention lack of active alerts or risk products only if that is useful context. -- Do not repeat deterministic tables or lists verbatim. - Do not include Markdown headings, bullets, or code fences. - Do not include fields outside the schema. - If a field cannot be supported by the data, keep it brief and conservative. diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 456d72b..4307f51 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -11,7 +11,7 @@ func TestTemplateLookup(t *testing.T) { if err != nil { t.Fatalf("Template() error = %v", err) } - for _, want := range []string{"# {{ .Report.Title }}", "## Summary", "## Hourly Forecast", "## Weather Story"} { + for _, want := range []string{"# {{ .Report.Title }}", "**Updated:**", "## Current Conditions", "## Hourly Forecast", "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } @@ -38,10 +38,10 @@ func TestSchemaLookup(t *testing.T) { if schema.AdditionalProperties { t.Fatal("additionalProperties = true, want false") } - if strings.Join(schema.Required, ",") != "summary,timing,impacts" { - t.Fatalf("required = %#v, want summary/timing/impacts", schema.Required) + if strings.Join(schema.Required, ",") != "summary,forecast_discussion" { + t.Fatalf("required = %#v, want summary/forecast_discussion", schema.Required) } - for _, field := range []string{"summary", "timing", "impacts", "confidence"} { + for _, field := range []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"} { property, ok := schema.Properties[field].(map[string]any) if !ok { t.Fatalf("schema property %q missing or invalid", field) @@ -58,34 +58,36 @@ func TestRenderHourly(t *testing.T) { Title: "Hourly Report", LocationName: "Brentwood", ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM", - GeneratedAtLabel: "May 29, 8:30 AM", + GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM", }, GeneratedText: testGeneratedText{ - Summary: "Storm chances increase through late morning.", - Timing: "The main window is 10 AM to noon.", - Impacts: "Brief downpours may slow travel.", - Confidence: "Medium confidence in timing.", + Summary: "Storm chances increase through late morning.", + ForecastDiscussion: "A front will keep the region unsettled.", + PrecipitationTiming: "A cold front is moving into the region.", + Confidence: "Medium confidence in timing.", }, Modules: testModules{ CurrentConditions: &testCurrentConditions{ ConditionText: "Partly cloudy", + ConditionTextLower: "partly cloudy", TemperatureF: floatPtr(74), ApparentTemperatureF: floatPtr(76), RelativeHumidityPercent: floatPtr(71), WindDirection: "S", + WindDirectionText: "south", WindSpeedMph: floatPtr(8), }, HourlyForecast: &testHourlyForecast{ Periods: []testHourlyPeriod{ - {PeriodBegins: "9 AM", TextDescription: "Cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), WindDirection: "S", WindSpeedMph: floatPtr(8)}, - {PeriodBegins: "10 AM", TextDescription: "Showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), WindDirection: "S", WindSpeedMph: floatPtr(10)}, + {HourLabel: "9:00 AM", TextDescription: "Cloudy", TextDescriptionLower: "cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(19), WindDirection: "S", WindSpeedMph: floatPtr(8)}, + {HourLabel: "10:00 AM", TextDescription: "Showers", TextDescriptionLower: "showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), MentionPrecipitation: true, WindDirection: "S", WindSpeedMph: floatPtr(10)}, }, }, PrecipTiming: &testPrecipTiming{ MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", PrecipitationWindows: []testPrecipWindow{ - {PeriodBegins: "10 AM", PeriodEnds: "12 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM"}, + {PeriodBegins: "10 AM", PeriodBeginsHourLabel: "10:00 AM", PeriodEnds: "12 PM", PeriodEndsHourLabel: "12:00 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", MaxPopHourLabel: "10:00 AM"}, }, }, AlertDigest: &testAlertDigest{ @@ -114,34 +116,71 @@ func TestRenderHourly(t *testing.T) { text := string(rendered) for _, want := range []string{ "# Hourly Report", - "Valid: May 29, 8:30 AM to 2:30 PM", + "**Updated:** Saturday, June 14, 2026 at 9:14 AM", "Storm chances increase through late morning.", - "## Confidence", - "- 10 AM: Showers; 75 F; 70% precipitation; wind S 10 mph", - "- Flood Watch: Flood Watch until 2:30 PM (Moderate)", - "Short-term discussion favors increasing rain coverage.", + "Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.", + "## Active Alerts", + "- **Flood Watch**: Flood Watch until 2:30 PM", + "- **9:00 AM:** 74°F and cloudy.", + "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", + "- **10:00 AM** to **12:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 10:00 AM.", + "A cold front is moving into the region.", + "A front will keep the region unsettled.", } { if !strings.Contains(text, want) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } + if strings.Contains(text, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") { + t.Fatalf("rendered template included omitted details:\n%s", text) + } assertOrderedText(t, text, []string{ "# Hourly Report", - "## Summary", - "## Timing", - "## Impacts", - "## Confidence", "## Current Conditions", + "## Active Alerts", "## Hourly Forecast", "## Precipitation Timing", - "## Alerts", - "## SPC Outlooks", "## Forecast Discussion", - "## SPC Discussion", - "## Weather Story", }) } +func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) { + rendered, err := Render("hourly", testRenderContext{ + Report: testReportContext{ + Title: "Hourly Report", + GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM", + }, + GeneratedText: testGeneratedText{ + Summary: "Dry weather is expected through the next several hours.", + ForecastDiscussion: "Quiet conditions should persist through midday.", + }, + Modules: testModules{ + CurrentConditions: &testCurrentConditions{ + ConditionTextLower: "cloudy", + TemperatureF: floatPtr(72), + }, + HourlyForecast: &testHourlyForecast{ + Periods: []testHourlyPeriod{ + {HourLabel: "9:00 AM", TextDescriptionLower: "mostly cloudy", TemperatureF: floatPtr(71), ProbabilityOfPrecipitationPercent: floatPtr(10)}, + }, + }, + AlertDigest: &testAlertDigest{}, + PrecipTiming: &testPrecipTiming{ + MaxPopPercent: intPtr(10), + }, + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, unwanted := range []string{"## Active Alerts", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} { + if strings.Contains(text, unwanted) { + t.Fatalf("clear render includes %q:\n%s", unwanted, text) + } + } +} + func TestUnknownAssetsReturnActionableErrors(t *testing.T) { if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { t.Fatalf("Template() error = %v, want unknown template", err) @@ -178,10 +217,10 @@ type testReportContext struct { } type testGeneratedText struct { - Summary string - Timing string - Impacts string - Confidence string + Summary string + ForecastDiscussion string + PrecipitationTiming string + Confidence string } type testModules struct { @@ -197,11 +236,13 @@ type testModules struct { type testCurrentConditions struct { ConditionText string + ConditionTextLower string TemperatureF *float64 ApparentTemperatureF *float64 RelativeHumidityPercent *float64 WindSpeedMph *float64 WindDirection string + WindDirectionText string } type testHourlyForecast struct { @@ -209,14 +250,17 @@ type testHourlyForecast struct { } type testHourlyPeriod struct { + HourLabel string PeriodBegins string Name string TextDescription string + TextDescriptionLower string TemperatureF *float64 WindSpeedMph *float64 WindGustMph *float64 WindDirection string ProbabilityOfPrecipitationPercent *float64 + MentionPrecipitation bool } type testPrecipTiming struct { @@ -227,10 +271,13 @@ type testPrecipTiming struct { } type testPrecipWindow struct { - PeriodBegins string - PeriodEnds string - MaxPopPercent *int - MaxPopTime string + PeriodBegins string + PeriodBeginsHourLabel string + PeriodEnds string + PeriodEndsHourLabel string + MaxPopPercent *int + MaxPopTime string + MaxPopHourLabel string } type testAlertDigest struct { diff --git a/internal/reporttemplate/schemas/hourly.generated_text.schema.json b/internal/reporttemplate/schemas/hourly.generated_text.schema.json index 07827e9..34f2bca 100644 --- a/internal/reporttemplate/schemas/hourly.generated_text.schema.json +++ b/internal/reporttemplate/schemas/hourly.generated_text.schema.json @@ -6,17 +6,16 @@ "additionalProperties": false, "required": [ "summary", - "timing", - "impacts" + "forecast_discussion" ], "properties": { "summary": { "type": "string" }, - "timing": { + "forecast_discussion": { "type": "string" }, - "impacts": { + "precipitation_timing": { "type": "string" }, "confidence": { diff --git a/internal/reporttemplate/templates/hourly.md.tmpl b/internal/reporttemplate/templates/hourly.md.tmpl index 76f4290..1513994 100644 --- a/internal/reporttemplate/templates/hourly.md.tmpl +++ b/internal/reporttemplate/templates/hourly.md.tmpl @@ -6,88 +6,34 @@ ## Current Conditions {{ with .Modules.CurrentConditions }} -{{ with .ConditionText }}{{ . }}{{ end }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ApparentTemperatureF }}; feels like {{ . }} F{{ end }}{{ with .RelativeHumidityPercent }}; humidity {{ . }}%{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}. +{{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }} {{ else }} -No current conditions available. +Current conditions are unavailable. {{ end }} +{{ with .Modules.AlertDigest }}{{ with .Relevant }} +## Active Alerts +{{ range . }} +- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**{{ with .Headline }}: {{ . }}{{ end }} +{{ end }} +{{ end }}{{ end }} ## Hourly Forecast {{ with .Modules.HourlyForecast }}{{ range .Periods }} -- **{{ .Name }}:** {{ .TextDescription }} and {{ .TemperatureF }}. Chance of precipitation {{ .ProbabilityOfPrecipitationPercent }}%. -{{ end }} +- **{{ if .HourLabel }}{{ .HourLabel }}{{ else }}{{ .Name }}{{ end }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }}{{ with .TextDescriptionLower }} and {{ . }}{{ else }}{{ with .TextDescription }} and {{ . }}{{ end }}{{ end }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }} +{{ else }} +- No hourly forecast rows are available. +{{ end }}{{ else }} +- No hourly forecast rows are available. {{ end }} +{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }} ## Precipitation Timing -{{ with .Modules.PrecipTiming }} -{{ with .MaxPopPercent }}Peak precipitation probability: {{ . }}%{{ with $.Modules.PrecipTiming.MaxPopTime }} at {{ . }}{{ end }}. -{{ end }}{{ range .PrecipitationWindows }}{{ $window := . }} -- Window: {{ .PeriodBegins }}{{ with .PeriodEnds }} to {{ . }}{{ end }}{{ with .MaxPopPercent }}; max {{ . }}%{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }} -{{ else }} -No precipitation windows above threshold. -{{ end }}{{ if .ThunderMentioned }} -Thunder is mentioned in the forecast. -{{ end }}{{ else }} -No precipitation timing signal above threshold. +{{ range . }}{{ $window := . }} +- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }} +{{ end }}{{ with $.GeneratedText.PrecipitationTiming }} +{{ . }} {{ end }} - -## Alerts -{{ with .Modules.AlertDigest }}{{ if .Missing }} -- Alert source missing. -{{ else }}{{ range .Relevant }} -- {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}{{ with .Headline }}: {{ . }}{{ end }}{{ with .Severity }} ({{ . }}){{ end }} -{{ else }} -- No active alert overlaps for this report period. -{{ end }}{{ end }}{{ else }} -- No active alert overlaps for this report period. -{{ end }} - -## SPC Outlooks -{{ with .Modules.SPCConvectiveOutlooks }}{{ range .Outlooks }} -- {{ if .LabelText }}{{ .LabelText }}{{ else }}{{ if .Label }}{{ .Label }}{{ else }}{{ .OutlookType }}{{ end }}{{ end }}{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }} -{{ else }} -- No overlapping SPC outlooks. -{{ end }}{{ else }} -- No overlapping SPC outlooks. -{{ end }} - +{{ end }}{{ end }} ## Forecast Discussion -{{ with .Modules.AreaForecastDiscussion }}{{ range .KeyMessages }} -- {{ . }} -{{ end }}{{ with .ShortTerm }} -{{ . }} -{{ end }}{{ else }} -No area forecast discussion available. -{{ end }} - -## SPC Discussion -{{ with .Modules.SPCConvectiveDiscussion }}{{ range .Discussions }} -- {{ if .Headline }}{{ .Headline }}{{ else }}{{ if .Summary }}{{ .Summary }}{{ else }}{{ .Discussion }}{{ end }}{{ end }}{{ with .Summary }}: {{ . }}{{ end }} -{{ else }} -- No overlapping SPC discussion. -{{ end }}{{ else }} -- No overlapping SPC discussion. -{{ end }} - -## Weather Story -{{ with .Modules.WeatherStory }}{{ if .Available }} -{{ with .Title }}{{ . }}{{ end }}{{ with .Description }} - {{ . }}{{ end }} -{{ else }} -No weather story available. -{{ end }}{{ else }} -No weather story available. -{{ end }} - -## Timing - -{{ .GeneratedText.Timing }} - -## Impacts - -{{ .GeneratedText.Impacts }} -{{ with .GeneratedText.Confidence }} - -## Confidence - -{{ . }} -{{ end }} \ No newline at end of file +{{ .GeneratedText.ForecastDiscussion }} diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 848b853..0f60641 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -232,7 +232,7 @@ func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) { if err != nil { t.Fatalf("SaveGeneratedTextResult() error = %v", err) } - generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`)) + generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`)) if err != nil { t.Fatalf("SaveGeneratedText() error = %v", err) } @@ -263,7 +263,7 @@ func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) { if err != nil { t.Fatalf("LoadGeneratedText() generated error = %v", err) } - if !strings.Contains(string(generatedData), `"timing":"Late morning."`) { + if !strings.Contains(string(generatedData), `"forecast_discussion":"A front will keep the region unsettled."`) { t.Fatalf("generated text = %q, want saved normalized JSON", generatedData) } var runResult struct {