From dc11e08e22e8cc0b740d2482c8c1d46d4ca4c8f6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 2 Jul 2026 11:05:31 -0500 Subject: [PATCH] Update the Alert Digest partial template to be more concise --- docs/internal/reporttemplate.md | 5 +- internal/app/app_test.go | 5 +- internal/generatedtext/render_context_test.go | 5 +- internal/reporttemplate/reporttemplate.go | 2 +- .../reporttemplate/reporttemplate_test.go | 90 +++++++++++++++++-- internal/reporttemplate/template_funcs.go | 76 ++++++++++++++++ .../templates/partials/alert_digest.md.tmpl | 8 +- 7 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 internal/reporttemplate/template_funcs.go diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md index 1353442..fae34c4 100644 --- a/docs/internal/reporttemplate.md +++ b/docs/internal/reporttemplate.md @@ -81,7 +81,10 @@ Today, Tomorrow, and Hourly call the shared `alert_digest` and template at render time and receive the same typed render context as the caller. The `alert_digest` partial renders the combined Alerts and Risk Products section from relevant NWS alerts and curated SPC outlook digest -records. +records. Rendered NWS alert bullets include alert identity and timing but omit +instruction and description text. Rendered SPC outlook bullets start at +Enhanced Risk; lower-risk SPC entries may still exist in module snapshots and +data packages. ## Schema Contract diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a4f8875..0b070e4 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -761,7 +761,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { "# Hourly Report", "Storm chances increase through late morning.", "## Alert Digest", - "- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM. Avoid low-water crossings.", + "- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM.", "## Precipitation Timing", "A cold front is moving into the region.", "A front will keep the region unsettled.", @@ -770,6 +770,9 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText) } } + if strings.Contains(reportText, "Avoid low-water crossings.") { + t.Fatalf("rendered hourly report includes alert instruction:\n%s", reportText) + } if result.OutputPath != result.ReportPath { t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath) } diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index e234a47..65b5e10 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) { "**Updated:** Friday, May 29, 2026 at 8:30 AM", "Storm chances increase through late morning.", "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", - "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.", + "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.", "A cold front is moving into the region.", "A front will keep the region unsettled.", } { @@ -99,6 +99,9 @@ func TestBuildHourlyRenderContext(t *testing.T) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } + if strings.Contains(text, "Avoid low-water crossings.") { + t.Fatalf("rendered template includes alert instruction:\n%s", text) + } } func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) { diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go index 5f31732..ea689c8 100644 --- a/internal/reporttemplate/reporttemplate.go +++ b/internal/reporttemplate/reporttemplate.go @@ -61,7 +61,7 @@ func Render(id string, data any) ([]byte, error) { if err != nil { return nil, err } - tmpl, err := template.New(id).Option("missingkey=error").Parse(source) + tmpl, err := template.New(id).Funcs(templateFuncs()).Option("missingkey=error").Parse(source) if err != nil { return nil, fmt.Errorf("parse report template %q: %w", id, err) } diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 9fb560a..2044643 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -270,8 +270,7 @@ func TestRenderHourly(t *testing.T) { "Storm chances increase through late morning.", "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.", "## Alert Digest", - "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.", - "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.", + "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 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**: Expect showers. The peak precipitation chance is 70% at 10:00 AM.", @@ -285,6 +284,11 @@ func TestRenderHourly(t *testing.T) { if strings.Contains(text, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") { t.Fatalf("rendered template included omitted details:\n%s", text) } + for _, unwanted := range []string{"Avoid low-water crossings.", "Slight risk for severe thunderstorms"} { + if strings.Contains(text, unwanted) { + t.Fatalf("rendered template included %q:\n%s", unwanted, text) + } + } assertOrderedText(t, text, []string{ "# Hourly Report", "## Alert Digest", @@ -371,8 +375,7 @@ func TestRenderTomorrow(t *testing.T) { "**Updated:** Sunday, June 14, 2026 at 9:14 AM", "Tomorrow starts dry before showers return later in the day.", "## Alert Digest", - "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", - "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", + "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.", "- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.", @@ -388,6 +391,8 @@ func TestRenderTomorrow(t *testing.T) { for _, unwanted := range []string{ "upper 50s.\n\n- **Morning:**", "upper 60s.\n\n- **Afternoon:**", + "Secure outdoor objects.", + "Slight risk for severe thunderstorms", } { if strings.Contains(text, unwanted) { t.Fatalf("rendered template includes blank lines between daypart bullets:\n%s", text) @@ -473,7 +478,7 @@ func TestRenderDaily(t *testing.T) { "**Updated:** Sunday, June 14, 2026 at 9:14 AM", "The selected day starts dry before showers return later in the day.", "## Alert Digest", - "- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.", + "- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM.", "- **SPC Convective Outlook**: Enhanced risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", @@ -486,6 +491,9 @@ func TestRenderDaily(t *testing.T) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } + if strings.Contains(text, "Monitor creek levels.") { + t.Fatalf("rendered template included alert description:\n%s", text) + } assertOrderedText(t, text, []string{ "# Monday's Weather", "The selected day starts dry before showers return later in the day.", @@ -581,8 +589,7 @@ func TestRenderToday(t *testing.T) { "**Updated:** Monday, June 15, 2026 at 7:14 AM", "Today starts dry before showers return later in the day.", "## Alert Digest", - "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", - "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", + "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.", "Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", @@ -606,7 +613,7 @@ func TestRenderToday(t *testing.T) { "## Precipitation Timing", "## Forecast Discussion", }) - for _, unwanted := range []string{"## Planning Notes", "Morning weather looks routine.", "Watch late-day shower timing.", "- **Evening:**", "Forecast details are limited"} { + for _, unwanted := range []string{"## Planning Notes", "Morning weather looks routine.", "Watch late-day shower timing.", "- **Evening:**", "Forecast details are limited", "Secure outdoor objects.", "Slight risk for severe thunderstorms"} { if strings.Contains(text, unwanted) { t.Fatalf("rendered template included %q:\n%s", unwanted, text) } @@ -736,6 +743,73 @@ func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) { } } +func TestAlertDigestOmitsBelowThresholdSPCRiskOnlySection(t *testing.T) { + rendered, err := Render("hourly", testRenderContext{ + Report: testReportContext{Title: "Hourly Report"}, + GeneratedText: testGeneratedText{ + Summary: "Storm chances remain low.", + ForecastDiscussion: "Only isolated severe storms are possible.", + }, + Modules: testModules{ + CurrentConditions: &testCurrentConditions{}, + HourlyForecast: &testHourlyForecast{}, + SPCConvectiveOutlooks: &testSPCOutlooks{ + RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, + }, + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, unwanted := range []string{"## Alert Digest", "SPC Convective Outlook", "Slight risk for severe thunderstorms"} { + if strings.Contains(text, unwanted) { + t.Fatalf("rendered template included %q for below-threshold SPC-only digest:\n%s", unwanted, text) + } + } +} + +func TestAlertDigestRendersEnhancedOrHigherSPCRiskWithoutAlerts(t *testing.T) { + tests := []struct { + label string + risk string + }{ + {label: "Enhanced Risk", risk: "Enhanced risk"}, + {label: "Moderate Risk", risk: "Moderate risk"}, + {label: "High Risk", risk: "High risk"}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + rendered, err := Render("hourly", testRenderContext{ + Report: testReportContext{Title: "Hourly Report"}, + GeneratedText: testGeneratedText{ + Summary: "Severe storms are possible.", + ForecastDiscussion: "SPC outlooks highlight the risk.", + }, + Modules: testModules{ + CurrentConditions: &testCurrentConditions{}, + HourlyForecast: &testHourlyForecast{}, + SPCConvectiveOutlooks: &testSPCOutlooks{ + RiskDigest: []testSPCRiskDigest{{LabelText: tt.label, RiskLabel: tt.risk, PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, + }, + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, want := range []string{ + "## Alert Digest", + "- **SPC Convective Outlook**: " + tt.risk + " for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered template missing %q:\n%s", want, text) + } + } + }) + } +} + func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) { rendered, err := Render("hourly", testRenderContext{ Report: testReportContext{ diff --git a/internal/reporttemplate/template_funcs.go b/internal/reporttemplate/template_funcs.go new file mode 100644 index 0000000..a659358 --- /dev/null +++ b/internal/reporttemplate/template_funcs.go @@ -0,0 +1,76 @@ +package reporttemplate + +import ( + "reflect" + "strings" + "text/template" +) + +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "hasRelevantAlerts": hasRelevantAlerts, + "hasEnhancedOrHigherSPCRisk": hasEnhancedOrHigherSPCRisk, + "isEnhancedOrHigherSPCRisk": isEnhancedOrHigherSPCRisk, + } +} + +func hasRelevantAlerts(alertDigest any) bool { + value := dereferenceValue(reflect.ValueOf(alertDigest)) + if !value.IsValid() || value.Kind() != reflect.Struct { + return false + } + relevant := value.FieldByName("Relevant") + return relevant.IsValid() && relevant.Kind() == reflect.Slice && relevant.Len() > 0 +} + +func hasEnhancedOrHigherSPCRisk(outlooks any) bool { + value := dereferenceValue(reflect.ValueOf(outlooks)) + if !value.IsValid() || value.Kind() != reflect.Struct { + return false + } + riskDigest := value.FieldByName("RiskDigest") + if !riskDigest.IsValid() || riskDigest.Kind() != reflect.Slice { + return false + } + for i := 0; i < riskDigest.Len(); i++ { + if isEnhancedOrHigherSPCRisk(riskDigest.Index(i).Interface()) { + return true + } + } + return false +} + +func isEnhancedOrHigherSPCRisk(risk any) bool { + value := dereferenceValue(reflect.ValueOf(risk)) + if !value.IsValid() || value.Kind() != reflect.Struct { + return false + } + label := stringField(value, "LabelText") + if label == "" { + label = stringField(value, "RiskLabel") + } + switch strings.ToLower(strings.TrimSpace(label)) { + case "enhanced risk", "moderate risk", "high risk": + return true + default: + return false + } +} + +func dereferenceValue(value reflect.Value) reflect.Value { + for value.IsValid() && (value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer) { + if value.IsNil() { + return reflect.Value{} + } + value = value.Elem() + } + return value +} + +func stringField(value reflect.Value, name string) string { + field := value.FieldByName(name) + if !field.IsValid() || field.Kind() != reflect.String { + return "" + } + return field.String() +} diff --git a/internal/reporttemplate/templates/partials/alert_digest.md.tmpl b/internal/reporttemplate/templates/partials/alert_digest.md.tmpl index 6537afc..1dd2fe1 100644 --- a/internal/reporttemplate/templates/partials/alert_digest.md.tmpl +++ b/internal/reporttemplate/templates/partials/alert_digest.md.tmpl @@ -1,8 +1,8 @@ -{{ define "alert_digest" }}{{ if or (and .Modules.AlertDigest .Modules.AlertDigest.Relevant) (and .Modules.SPCConvectiveOutlooks .Modules.SPCConvectiveOutlooks.RiskDigest) }}## Alert Digest +{{ define "alert_digest" }}{{ $hasAlerts := hasRelevantAlerts .Modules.AlertDigest }}{{ $hasSPC := hasEnhancedOrHigherSPCRisk .Modules.SPCConvectiveOutlooks }}{{ if or $hasAlerts $hasSPC }}## Alert Digest {{ with .Modules.AlertDigest }}{{ range .Relevant -}} -- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**: {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }} in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.{{ with .Instruction }} {{ . }}{{ else }}{{ with .Description }} {{ . }}{{ end }}{{ end }} -{{ end }}{{ end }}{{ with .Modules.SPCConvectiveOutlooks }}{{ range .RiskDigest -}} +- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**: {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }} in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}. +{{ end }}{{ end }}{{ with .Modules.SPCConvectiveOutlooks }}{{ range .RiskDigest }}{{ if isEnhancedOrHigherSPCRisk . -}} - **SPC Convective Outlook**: {{ with .RiskLabel }}{{ . }}{{ else }}Convective risk{{ end }} for severe thunderstorms in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}. -{{ end }}{{ end }} +{{ end }}{{ end }}{{ end }} {{ end }}{{ end }}