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
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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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{

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 -}}
- **{{ 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 }}