Simplify and rationalize the hourly report template
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user