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

@@ -29,14 +29,15 @@ The hourly generated text JSON accepts:
```json
{
"summary": "string",
"timing": "string",
"impacts": "string",
"forecast_discussion": "string",
"precipitation_timing": "string",
"confidence": "string"
}
```
`summary`, `timing`, and `impacts` are required after trimming whitespace.
`confidence` is optional and omitted from normalized JSON when blank.
`summary` and `forecast_discussion` are required after trimming whitespace.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
## Boundaries
@@ -54,7 +55,7 @@ The hourly generated text JSON accepts:
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required hourly fields fail after trimming whitespace.
- Missing required render-context stanzas fail with the stanza name.
- Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.

View File

@@ -61,12 +61,11 @@ The hourly GeneratedText schema describes the structured prose Scriptorium is
expected to write for the prompt. It requires:
- `summary`
- `timing`
- `impacts`
- `forecast_discussion`
It allows optional `confidence` and rejects additional properties. Weather truth
remains in module outputs; GeneratedText is limited to prose slots consumed by
the template.
It allows optional `precipitation_timing` and `confidence`, and rejects
additional properties. Weather truth remains in module outputs; GeneratedText is
limited to prose slots consumed by the template.
## Failure Behavior

View File

@@ -63,22 +63,18 @@ weatherreporter, and then inserted into the render context.
| Variable | Type | Description |
| --- | --- | --- |
| `.GeneratedText.Summary` | string | Required short prose summary. |
| `.GeneratedText.Timing` | string | Required prose about timing of notable weather changes or hazards. |
| `.GeneratedText.Impacts` | string | Required prose about practical near-term impacts. |
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM. |
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
Example:
```gotemplate
## Summary
{{ .GeneratedText.Summary }}
{{ with .GeneratedText.Confidence }}
## Confidence
## Forecast Discussion
{{ . }}
{{ end }}
{{ .GeneratedText.ForecastDiscussion }}
```
## Modules
@@ -106,10 +102,12 @@ Common fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
| `.Modules.CurrentConditions.TemperatureF` | *float64 | Current temperature. |
| `.Modules.CurrentConditions.ApparentTemperatureF` | *float64 | Apparent temperature. |
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *float64 | Relative humidity. |
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
| `.Modules.CurrentConditions.WindSpeedMph` | *float64 | Wind speed. |
Example:
@@ -129,12 +127,15 @@ Common period fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
@@ -143,7 +144,7 @@ Example:
```gotemplate
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
- {{ .PeriodBegins }}: {{ .TextDescription }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ProbabilityOfPrecipitationPercent }}; {{ . }}% precipitation{{ end }}
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}{{ end }}
@@ -160,9 +161,12 @@ Common fields:
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
### Alert Digest

View File

@@ -386,8 +386,8 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
},
structuredRunBody: `{
"summary": " Storm chances increase through late morning. ",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"forecast_discussion": "A front will keep the region unsettled.",
"precipitation_timing": "A cold front is moving into the region.",
"confidence": "Medium"
}`,
}
@@ -443,7 +443,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
if err != nil {
t.Fatalf("read validated generated text: %v", err)
}
if string(normalized) != `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}` {
if string(normalized) != `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` {
t.Fatalf("validated generated text = %s, want normalized JSON", normalized)
}
renderContext, err := os.ReadFile(result.RenderContextPath)
@@ -473,10 +473,11 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"The main window is 10 AM to noon.",
"Flood Watch: Flooding possible (Moderate)",
"Short-term AFD narrative for hourly report.",
"Hourly Storm Chances - Scattered showers and thunderstorms are possible.",
"## Active Alerts",
"- **Flood Watch**: Flooding possible",
"## Precipitation Timing",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
if !strings.Contains(reportText, want) {
t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText)
@@ -734,8 +735,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: `{
"summary": "Storm chances increase through late morning.",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"forecast_discussion": "A front will keep the region unsettled.",
"details": "not allowed"
}`,
}
@@ -2058,7 +2058,7 @@ func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.
}
func validHourlyGeneratedTextJSON() string {
return `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}`
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
}
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {

View File

@@ -59,7 +59,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
}
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
@@ -68,12 +68,15 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
}
if !period.MentionPrecipitation {
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
}
@@ -86,6 +89,27 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
}
}
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
periods := []weatherdata.ForecastPeriod{
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
}
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
if len(value) != 3 {
t.Fatalf("periods length = %d, want 3", len(value))
}
if value[0].MentionPrecipitation {
t.Fatalf("19%% MentionPrecipitation = true, want false")
}
if !value[1].MentionPrecipitation {
t.Fatalf("20%% MentionPrecipitation = false, want true")
}
if value[2].MentionPrecipitation {
t.Fatalf("nil MentionPrecipitation = true, want false")
}
}
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -209,18 +233,21 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[CurrentConditionsModule](t, output)
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
}
if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
}
if value.WindDirectionText != "south" {
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal current conditions: %v", err)
}
jsonText := string(data)
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field)
}

View File

@@ -1,9 +1,14 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"`
ConditionTextLower string `json:"condition_text_lower,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
@@ -15,6 +20,7 @@ type CurrentConditionsModule struct {
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
WindDirectionText string `json:"wind_direction_text,omitempty"`
}
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -24,6 +30,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
}
value := CurrentConditionsModule{
ConditionText: current.ConditionText,
ConditionTextLower: strings.ToLower(current.ConditionText),
IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF),
@@ -35,6 +42,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph),
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
}
if value.isEmpty() {
return nil, nil
@@ -44,6 +52,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
func (v CurrentConditionsModule) isEmpty() bool {
return v.ConditionText == "" &&
v.ConditionTextLower == "" &&
v.IsDay == nil &&
v.TemperatureC == nil &&
v.TemperatureF == nil &&
@@ -54,5 +63,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
v.RelativeHumidityPercent == nil &&
v.WindSpeedKmh == nil &&
v.WindSpeedMph == nil &&
v.WindDirection == ""
v.WindDirection == "" &&
v.WindDirectionText == ""
}

View File

@@ -102,17 +102,17 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
}
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
}
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal precip timing: %v", err)
}
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
}
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {

View File

@@ -1,6 +1,7 @@
package briefing
import (
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -8,6 +9,8 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
type HourlyForecastModule struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
@@ -18,12 +21,14 @@ type HourlyForecastModule struct {
}
type HourlyForecastPeriod struct {
HourLabel string `json:"hour_label,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TextDescriptionLower string `json:"text_description_lower,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
@@ -45,6 +50,7 @@ type HourlyForecastPeriod struct {
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
@@ -73,16 +79,22 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
}
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
}
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, HourlyForecastPeriod{
HourLabel: hourMinuteLabel(period.StartTime, timezone),
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),
TextDescription: period.TextDescription,
TextDescriptionLower: strings.ToLower(period.TextDescription),
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
@@ -104,6 +116,7 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
@@ -115,6 +128,10 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
return out
}
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
return probability != nil && *probability >= threshold
}
func (v HourlyForecastModule) isEmpty() bool {
return v.Product == "" &&
v.IssuedAt.IsZero() &&

View File

@@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string {
return labels[sector]
}
func windDirectionTextLabel(degrees *float64) string {
if degrees == nil {
return ""
}
labels := []string{
"north",
"north-northeast",
"northeast",
"east-northeast",
"east",
"east-southeast",
"southeast",
"south-southeast",
"south",
"south-southwest",
"southwest",
"west-southwest",
"west",
"west-northwest",
"northwest",
"north-northwest",
}
normalized := math.Mod(*degrees, 360)
if normalized < 0 {
normalized += 360
}
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
return labels[sector]
}
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil {
return ""
@@ -126,3 +156,11 @@ func clockLabel(value time.Time, timezone string) string {
}
return label
}
func hourMinuteLabel(value time.Time, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("3:04 PM")
}

View File

@@ -27,3 +27,25 @@ func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
})
}
}
func TestWindDirectionTextLabelUsesLowercaseCompassText(t *testing.T) {
tests := []struct {
name string
degrees *float64
want string
}{
{name: "nil", degrees: nil, want: ""},
{name: "north", degrees: floatPtr(0), want: "north"},
{name: "north northeast", degrees: floatPtr(11.25), want: "north-northeast"},
{name: "northwest", degrees: floatPtr(315), want: "northwest"},
{name: "negative normalizes", degrees: floatPtr(-45), want: "northwest"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := windDirectionTextLabel(tt.degrees); got != tt.want {
t.Fatalf("windDirectionTextLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
}
})
}
}

View File

@@ -14,10 +14,13 @@ type PrecipTimingModule struct {
}
type PrecipitationWindowModule struct {
PeriodBegins string `json:"period_begins"`
PeriodEnds string `json:"period_ends,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
PeriodBegins string `json:"period_begins"`
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
}
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -36,13 +39,16 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
}
for _, window := range timing.PrecipitationWindows {
item := PrecipitationWindowModule{
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
}
if window.End != nil {
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
}
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
}
return value

View File

@@ -715,8 +715,8 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"The main window is 10 AM to noon.",
"Brief downpours may slow travel.",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
if !strings.Contains(string(report), want) {
t.Fatalf("report output missing %q:\n%s", want, string(report))
@@ -1221,8 +1221,8 @@ if [ "$1" = "run" ]; then
cat > "$out" <<'JSON'
{
"summary": " Storm chances increase through late morning. ",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"forecast_discussion": "A front will keep the region unsettled.",
"precipitation_timing": "A cold front is moving into the region.",
"confidence": "Medium"
}
JSON

View File

@@ -10,10 +10,10 @@ import (
)
type Hourly struct {
Summary string `json:"summary"`
Timing string `json:"timing"`
Impacts string `json:"impacts"`
Confidence string `json:"confidence,omitempty"`
Summary string `json:"summary"`
ForecastDiscussion string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateHourly(data []byte) (Hourly, []byte, error) {
@@ -34,17 +34,14 @@ func ValidateHourly(data []byte) (Hourly, []byte, error) {
}
value.Summary = strings.TrimSpace(value.Summary)
value.Timing = strings.TrimSpace(value.Timing)
value.Impacts = strings.TrimSpace(value.Impacts)
value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
if value.Summary == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
}
if value.Timing == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text timing is required")
}
if value.Impacts == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text impacts is required")
if value.ForecastDiscussion == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
}
normalized, err := json.Marshal(value)

View File

@@ -7,10 +7,10 @@ import (
func TestValidateHourlyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateHourly([]byte(`{
"timing": " main window late morning ",
"summary": " Storm chances increase. ",
"confidence": " Medium ",
"impacts": " Brief downpours. "
"forecast_discussion": " A front will keep the region unsettled. ",
"precipitation_timing": " Showers are most likely early this afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
@@ -18,7 +18,13 @@ func TestValidateHourlyNormalizesJSON(t *testing.T) {
if value.Summary != "Storm chances increase." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
want := `{"summary":"Storm chances increase.","timing":"main window late morning","impacts":"Brief downpours.","confidence":"Medium"}`
if value.ForecastDiscussion != "A front will keep the region unsettled." {
t.Fatalf("ForecastDiscussion = %q, want trimmed discussion", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Showers are most likely early this afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"Showers are most likely early this afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
@@ -27,14 +33,14 @@ func TestValidateHourlyNormalizesJSON(t *testing.T) {
func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) {
_, normalized, err := ValidateHourly([]byte(`{
"summary": "Storm chances increase.",
"timing": "Late morning.",
"impacts": "Brief downpours.",
"forecast_discussion": "A front will keep the region unsettled.",
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
}
want := `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
@@ -53,22 +59,32 @@ func TestValidateHourlyRejectsInvalidInput(t *testing.T) {
},
{
name: "unknown field",
in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours.","extra":"value"}`,
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"timing":"Late morning.","impacts":"Brief downpours."}`,
in: `{"forecast_discussion":"A front will keep the region unsettled."}`,
want: "summary is required",
},
{
name: "blank timing",
in: `{"summary":"Storm chances increase.","timing":" ","impacts":"Brief downpours."}`,
want: "timing is required",
name: "blank forecast discussion",
in: `{"summary":"Storm chances increase.","forecast_discussion":" "}`,
want: "forecast discussion is required",
},
{
name: "old timing field rejected",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","timing":"Late morning."}`,
want: `unknown field "timing"`,
},
{
name: "old impacts field rejected",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","impacts":"Brief downpours."}`,
want: `unknown field "impacts"`,
},
{
name: "multiple values",
in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."} {}`,
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."} {}`,
want: "multiple JSON values",
},
}

View File

@@ -60,7 +60,7 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
Title: "Hourly Report",
LocationName: locationName(metadata),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location),
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
Timezone: metadata.Timezone,
@@ -159,3 +159,7 @@ func periodLabel(period timeutil.Period, location *time.Location) string {
func timeLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func generatedAtLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("Monday, January 2, 2006 at 3:04 PM")
}

View File

@@ -18,10 +18,10 @@ func TestBuildHourlyRenderContext(t *testing.T) {
metadata := testMetadata()
snapshot := testSnapshot(t)
generated := Hourly{
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.",
}
collected := testCollected()
derived := testDerived()
@@ -38,6 +38,9 @@ func TestBuildHourlyRenderContext(t *testing.T) {
if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
t.Fatalf("Report.ValidPeriodLabel = %q, want friendly period", ctx.Report.ValidPeriodLabel)
}
if ctx.Report.GeneratedAtLabel != "Friday, May 29, 2026 at 8:30 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
}
@@ -79,10 +82,12 @@ func TestBuildHourlyRenderContext(t *testing.T) {
text := string(rendered)
for _, want := range []string{
"# Hourly Report",
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.",
"- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph",
"- Flood Watch: Flood Watch until early afternoon (Moderate)",
"Morning storms - Morning storms remain the main story.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **Flood Watch**: Flood Watch until early afternoon",
"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)
@@ -98,9 +103,8 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
t.Fatalf("NewSnapshot() error = %v", err)
}
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
Timing: "Late morning.",
Impacts: "Brief downpours.",
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{})
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
@@ -146,11 +150,13 @@ func testSnapshot(t *testing.T) module.Snapshot {
StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{
ConditionText: "Partly cloudy",
ConditionTextLower: "partly cloudy",
TemperatureF: floatPtr(74),
ApparentTemperatureF: floatPtr(76),
RelativeHumidityPercent: floatPtr(71),
WindSpeedMph: floatPtr(8),
WindDirection: "S",
WindDirectionText: "south",
},
},
{
@@ -159,18 +165,24 @@ func testSnapshot(t *testing.T) module.Snapshot {
Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{
{
HourLabel: "9:00 AM",
PeriodBegins: "2026-05-29 at 9:00 AM",
TextDescription: "Cloudy",
TextDescriptionLower: "cloudy",
TemperatureF: floatPtr(74),
ProbabilityOfPrecipitationPercent: floatPtr(30),
MentionPrecipitation: true,
WindSpeedMph: floatPtr(8),
WindDirection: "S",
},
{
HourLabel: "10:00 AM",
PeriodBegins: "2026-05-29 at 10:00 AM",
TextDescription: "Showers",
TextDescriptionLower: "showers",
TemperatureF: floatPtr(75),
ProbabilityOfPrecipitationPercent: floatPtr(70),
MentionPrecipitation: true,
WindSpeedMph: floatPtr(10),
WindGustMph: floatPtr(18),
WindDirection: "S",
@@ -187,10 +199,13 @@ func testSnapshot(t *testing.T) module.Snapshot {
ProbabilityThreshold: 50,
PrecipitationWindows: []briefing.PrecipitationWindowModule{
{
PeriodBegins: "2026-05-29 at 10:00 AM",
PeriodEnds: "2026-05-29 at 12:00 PM",
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
PeriodBegins: "2026-05-29 at 10:00 AM",
PeriodBeginsHourLabel: "10:00 AM",
PeriodEnds: "2026-05-29 at 12:00 PM",
PeriodEndsHourLabel: "12:00 PM",
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
MaxPopHourLabel: "10:00 AM",
},
},
ThunderMentioned: true,

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

View File

@@ -232,7 +232,7 @@ func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("SaveGeneratedTextResult() error = %v", err)
}
generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`))
generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`))
if err != nil {
t.Fatalf("SaveGeneratedText() error = %v", err)
}
@@ -263,7 +263,7 @@ func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("LoadGeneratedText() generated error = %v", err)
}
if !strings.Contains(string(generatedData), `"timing":"Late morning."`) {
if !strings.Contains(string(generatedData), `"forecast_discussion":"A front will keep the region unsettled."`) {
t.Fatalf("generated text = %q, want saved normalized JSON", generatedData)
}
var runResult struct {