Update the Alert Digest partial template to be more concise

This commit is contained in:
2026-07-02 11:05:31 -05:00
parent fdddb5f08d
commit dc11e08e22
7 changed files with 175 additions and 16 deletions

View File

@@ -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 template at render time and receive the same typed render context as the
caller. The `alert_digest` partial renders the combined Alerts and Risk caller. The `alert_digest` partial renders the combined Alerts and Risk
Products section from relevant NWS alerts and curated SPC outlook digest 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 ## Schema Contract

View File

@@ -761,7 +761,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
"# Hourly Report", "# Hourly Report",
"Storm chances increase through late morning.", "Storm chances increase through late morning.",
"## Alert Digest", "## 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", "## Precipitation Timing",
"A cold front is moving into the region.", "A cold front is moving into the region.",
"A front will keep the region unsettled.", "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) 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 { if result.OutputPath != result.ReportPath {
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath) t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
} }

View File

@@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) {
"**Updated:** Friday, May 29, 2026 at 8:30 AM", "**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.", "Storm chances increase through late morning.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", "- **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 cold front is moving into the region.",
"A front will keep the region unsettled.", "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) 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) { func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {

View File

@@ -61,7 +61,7 @@ func Render(id string, data any) ([]byte, error) {
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err) return nil, fmt.Errorf("parse report template %q: %w", id, err)
} }

View File

@@ -270,8 +270,7 @@ func TestRenderHourly(t *testing.T) {
"Storm chances increase through late morning.", "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.", "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", "## 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.", "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.",
"- **9:00 AM:** 74°F and cloudy.", "- **9:00 AM:** 74°F and cloudy.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", "- **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.", "- **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") { if strings.Contains(text, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") {
t.Fatalf("rendered template included omitted details:\n%s", text) 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{ assertOrderedText(t, text, []string{
"# Hourly Report", "# Hourly Report",
"## Alert Digest", "## Alert Digest",
@@ -371,8 +375,7 @@ func TestRenderTomorrow(t *testing.T) {
"**Updated:** Sunday, June 14, 2026 at 9:14 AM", "**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"Tomorrow starts dry before showers return later in the day.", "Tomorrow starts dry before showers return later in the day.",
"## Alert Digest", "## 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.", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.", "- **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.", "- **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%.", "- **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{ for _, unwanted := range []string{
"upper 50s.\n\n- **Morning:**", "upper 50s.\n\n- **Morning:**",
"upper 60s.\n\n- **Afternoon:**", "upper 60s.\n\n- **Afternoon:**",
"Secure outdoor objects.",
"Slight risk for severe thunderstorms",
} { } {
if strings.Contains(text, unwanted) { if strings.Contains(text, unwanted) {
t.Fatalf("rendered template includes blank lines between daypart bullets:\n%s", text) 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", "**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"The selected day starts dry before showers return later in the day.", "The selected day starts dry before showers return later in the day.",
"## Alert Digest", "## 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.", "- **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.", "- **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%.", "- **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) 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{ assertOrderedText(t, text, []string{
"# Monday's Weather", "# Monday's Weather",
"The selected day starts dry before showers return later in the day.", "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", "**Updated:** Monday, June 15, 2026 at 7:14 AM",
"Today starts dry before showers return later in the day.", "Today starts dry before showers return later in the day.",
"## Alert Digest", "## 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.", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"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.", "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.", "- **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%.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -606,7 +613,7 @@ func TestRenderToday(t *testing.T) {
"## Precipitation Timing", "## Precipitation Timing",
"## Forecast Discussion", "## 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) { if strings.Contains(text, unwanted) {
t.Fatalf("rendered template included %q:\n%s", unwanted, text) 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) { func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{ rendered, err := Render("hourly", testRenderContext{
Report: testReportContext{ Report: testReportContext{

View File

@@ -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()
}

View File

@@ -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 -}} {{ 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 }} - **{{ 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 -}} {{ 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 }}. - **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 }} {{ end }}{{ end }}