Simplify and rationalize the hourly report template

This commit is contained in:
2026-06-14 12:54:32 -05:00
parent ceb00ad45e
commit 74e32eb18e
21 changed files with 363 additions and 211 deletions

View File

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

View File

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

View File

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

View File

@@ -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 }}
{{ .GeneratedText.ForecastDiscussion }}