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 ```json
{ {
"summary": "string", "summary": "string",
"timing": "string", "forecast_discussion": "string",
"impacts": "string", "precipitation_timing": "string",
"confidence": "string" "confidence": "string"
} }
``` ```
`summary`, `timing`, and `impacts` are required after trimming whitespace. `summary` and `forecast_discussion` are required after trimming whitespace.
`confidence` is optional and omitted from normalized JSON when blank. `precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
## Boundaries ## Boundaries
@@ -54,7 +55,7 @@ The hourly generated text JSON accepts:
- Malformed generated-text JSON fails with decode context. - Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding. - Unknown generated-text JSON fields fail during decoding.
- Empty required hourly fields fail after trimming whitespace. - 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, - Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering. 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: expected to write for the prompt. It requires:
- `summary` - `summary`
- `timing` - `forecast_discussion`
- `impacts`
It allows optional `confidence` and rejects additional properties. Weather truth It allows optional `precipitation_timing` and `confidence`, and rejects
remains in module outputs; GeneratedText is limited to prose slots consumed by additional properties. Weather truth remains in module outputs; GeneratedText is
the template. limited to prose slots consumed by the template.
## Failure Behavior ## Failure Behavior

View File

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

View File

@@ -386,8 +386,8 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
}, },
structuredRunBody: `{ structuredRunBody: `{
"summary": " Storm chances increase through late morning. ", "summary": " Storm chances increase through late morning. ",
"timing": "The main window is 10 AM to noon.", "forecast_discussion": "A front will keep the region unsettled.",
"impacts": "Brief downpours may slow travel.", "precipitation_timing": "A cold front is moving into the region.",
"confidence": "Medium" "confidence": "Medium"
}`, }`,
} }
@@ -443,7 +443,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read validated generated text: %v", err) 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) t.Fatalf("validated generated text = %s, want normalized JSON", normalized)
} }
renderContext, err := os.ReadFile(result.RenderContextPath) renderContext, err := os.ReadFile(result.RenderContextPath)
@@ -473,10 +473,11 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"# Hourly Report", "# Hourly Report",
"Storm chances increase through late morning.", "Storm chances increase through late morning.",
"The main window is 10 AM to noon.", "## Active Alerts",
"Flood Watch: Flooding possible (Moderate)", "- **Flood Watch**: Flooding possible",
"Short-term AFD narrative for hourly report.", "## Precipitation Timing",
"Hourly Storm Chances - Scattered showers and thunderstorms are possible.", "A cold front is moving into the region.",
"A front will keep the region unsettled.",
} { } {
if !strings.Contains(reportText, want) { if !strings.Contains(reportText, want) {
t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText) 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}, structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: `{ structuredRunBody: `{
"summary": "Storm chances increase through late morning.", "summary": "Storm chances increase through late morning.",
"timing": "The main window is 10 AM to noon.", "forecast_discussion": "A front will keep the region unsettled.",
"impacts": "Brief downpours may slow travel.",
"details": "not allowed" "details": "not allowed"
}`, }`,
} }
@@ -2058,7 +2058,7 @@ func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.
} }
func validHourlyGeneratedTextJSON() string { 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 { 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) t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
} }
period := value.Periods[0] 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) 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" { 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 { if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) 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) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err) t.Fatalf("Marshal hourly forecast: %v", err)
} }
jsonText := string(data) 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) { if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", 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) { func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -209,18 +233,21 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err) t.Fatalf("BuildModule() error = %v", err)
} }
value := moduleValue[CurrentConditionsModule](t, output) 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) t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
} }
if value.WindDirection != "S" { if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection) 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) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal current conditions: %v", err) t.Fatalf("Marshal current conditions: %v", err)
} }
jsonText := string(data) 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) { if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field) t.Fatalf("current json = %s, want field %s", jsonText, field)
} }

View File

@@ -1,9 +1,14 @@
package briefing package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type CurrentConditionsModule struct { type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"` ConditionText string `json:"condition_text,omitempty"`
ConditionTextLower string `json:"condition_text_lower,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"` TemperatureF *float64 `json:"temperature_f,omitempty"`
@@ -15,6 +20,7 @@ type CurrentConditionsModule struct {
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"` WindDirection string `json:"wind_direction,omitempty"`
WindDirectionText string `json:"wind_direction_text,omitempty"`
} }
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -24,6 +30,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
} }
value := CurrentConditionsModule{ value := CurrentConditionsModule{
ConditionText: current.ConditionText, ConditionText: current.ConditionText,
ConditionTextLower: strings.ToLower(current.ConditionText),
IsDay: copyBool(current.IsDay), IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC), TemperatureC: copyFloat(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF), TemperatureF: copyFloat(current.TemperatureF),
@@ -35,6 +42,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
WindSpeedKmh: copyFloat(current.WindSpeedKmh), WindSpeedKmh: copyFloat(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph), WindSpeedMph: copyFloat(current.WindSpeedMph),
WindDirection: windDirectionLabel(current.WindDirectionDegrees), WindDirection: windDirectionLabel(current.WindDirectionDegrees),
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
} }
if value.isEmpty() { if value.isEmpty() {
return nil, nil return nil, nil
@@ -44,6 +52,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
func (v CurrentConditionsModule) isEmpty() bool { func (v CurrentConditionsModule) isEmpty() bool {
return v.ConditionText == "" && return v.ConditionText == "" &&
v.ConditionTextLower == "" &&
v.IsDay == nil && v.IsDay == nil &&
v.TemperatureC == nil && v.TemperatureC == nil &&
v.TemperatureF == nil && v.TemperatureF == nil &&
@@ -54,5 +63,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
v.RelativeHumidityPercent == nil && v.RelativeHumidityPercent == nil &&
v.WindSpeedKmh == nil && v.WindSpeedKmh == nil &&
v.WindSpeedMph == 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 { if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) 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]) 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]) t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
} }
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("marshal precip timing: %v", err) 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)) t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
} }
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) { if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {

View File

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

View File

@@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string {
return labels[sector] 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 { func timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil { if value == nil {
return "" return ""
@@ -126,3 +156,11 @@ func clockLabel(value time.Time, timezone string) string {
} }
return label 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

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

View File

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

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
Title: "Hourly Report", Title: "Hourly Report",
LocationName: locationName(metadata), LocationName: locationName(metadata),
GeneratedAt: metadata.GeneratedAt, GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location), GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location), ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
Timezone: metadata.Timezone, 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 { func timeLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("2006-01-02 at 3:04 PM") 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

@@ -19,8 +19,8 @@ func TestBuildHourlyRenderContext(t *testing.T) {
snapshot := testSnapshot(t) snapshot := testSnapshot(t)
generated := Hourly{ generated := Hourly{
Summary: "Storm chances increase through late morning.", Summary: "Storm chances increase through late morning.",
Timing: "The main window is 10 AM to noon.", ForecastDiscussion: "A front will keep the region unsettled.",
Impacts: "Brief downpours may slow travel.", PrecipitationTiming: "A cold front is moving into the region.",
Confidence: "Medium confidence in timing.", Confidence: "Medium confidence in timing.",
} }
collected := testCollected() collected := testCollected()
@@ -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" { 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) 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 { 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) t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
} }
@@ -79,10 +82,12 @@ func TestBuildHourlyRenderContext(t *testing.T) {
text := string(rendered) text := string(rendered)
for _, want := range []string{ for _, want := range []string{
"# Hourly Report", "# Hourly Report",
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.", "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", "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- Flood Watch: Flood Watch until early afternoon (Moderate)", "- **Flood Watch**: Flood Watch until early afternoon",
"Morning storms - Morning storms remain the main story.", "A cold front is moving into the region.",
"A front will keep the region unsettled.",
} { } {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text) t.Fatalf("rendered template missing %q:\n%s", want, text)
@@ -99,8 +104,7 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
} }
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.", Summary: "Storm chances increase.",
Timing: "Late morning.", ForecastDiscussion: "A front will keep the region unsettled.",
Impacts: "Brief downpours.",
}, testCollected(), facts.DerivedFacts{}) }, testCollected(), facts.DerivedFacts{})
if err != nil { if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err) t.Fatalf("BuildHourlyRenderContext() error = %v", err)
@@ -146,11 +150,13 @@ func testSnapshot(t *testing.T) module.Snapshot {
StanzaName: string(module.CurrentConditions), StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{ Value: briefing.CurrentConditionsModule{
ConditionText: "Partly cloudy", ConditionText: "Partly cloudy",
ConditionTextLower: "partly cloudy",
TemperatureF: floatPtr(74), TemperatureF: floatPtr(74),
ApparentTemperatureF: floatPtr(76), ApparentTemperatureF: floatPtr(76),
RelativeHumidityPercent: floatPtr(71), RelativeHumidityPercent: floatPtr(71),
WindSpeedMph: floatPtr(8), WindSpeedMph: floatPtr(8),
WindDirection: "S", WindDirection: "S",
WindDirectionText: "south",
}, },
}, },
{ {
@@ -159,18 +165,24 @@ func testSnapshot(t *testing.T) module.Snapshot {
Value: briefing.HourlyForecastModule{ Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{ Periods: []briefing.HourlyForecastPeriod{
{ {
HourLabel: "9:00 AM",
PeriodBegins: "2026-05-29 at 9:00 AM", PeriodBegins: "2026-05-29 at 9:00 AM",
TextDescription: "Cloudy", TextDescription: "Cloudy",
TextDescriptionLower: "cloudy",
TemperatureF: floatPtr(74), TemperatureF: floatPtr(74),
ProbabilityOfPrecipitationPercent: floatPtr(30), ProbabilityOfPrecipitationPercent: floatPtr(30),
MentionPrecipitation: true,
WindSpeedMph: floatPtr(8), WindSpeedMph: floatPtr(8),
WindDirection: "S", WindDirection: "S",
}, },
{ {
HourLabel: "10:00 AM",
PeriodBegins: "2026-05-29 at 10:00 AM", PeriodBegins: "2026-05-29 at 10:00 AM",
TextDescription: "Showers", TextDescription: "Showers",
TextDescriptionLower: "showers",
TemperatureF: floatPtr(75), TemperatureF: floatPtr(75),
ProbabilityOfPrecipitationPercent: floatPtr(70), ProbabilityOfPrecipitationPercent: floatPtr(70),
MentionPrecipitation: true,
WindSpeedMph: floatPtr(10), WindSpeedMph: floatPtr(10),
WindGustMph: floatPtr(18), WindGustMph: floatPtr(18),
WindDirection: "S", WindDirection: "S",
@@ -188,9 +200,12 @@ func testSnapshot(t *testing.T) module.Snapshot {
PrecipitationWindows: []briefing.PrecipitationWindowModule{ PrecipitationWindows: []briefing.PrecipitationWindowModule{
{ {
PeriodBegins: "2026-05-29 at 10:00 AM", PeriodBegins: "2026-05-29 at 10:00 AM",
PeriodBeginsHourLabel: "10:00 AM",
PeriodEnds: "2026-05-29 at 12:00 PM", PeriodEnds: "2026-05-29 at 12:00 PM",
PeriodEndsHourLabel: "12:00 PM",
MaxPopPercent: intPtr(70), MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM", MaxPopTime: "10 AM",
MaxPopHourLabel: "10:00 AM",
}, },
}, },
ThunderMentioned: true, 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 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 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: Return these fields:
- `summary`: 1-2 sentences summarizing the main weather story for the valid - `summary`: required. 1-2 sentences summarizing the main weather story for the
period. valid period.
- `timing`: 1-3 sentences explaining when the most important changes or hazards - `forecast_discussion`: required. 1-3 sentences explaining the broader setup,
are expected during the valid period. trend, or forecast reasoning most relevant to the valid period.
- `impacts`: 1-3 sentences explaining practical impacts for people planning - `precipitation_timing`: optional. Include only when the deterministic
travel, outdoor activity, errands, commuting, or similar near-term decisions. `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 - `confidence`: optional. Include only if uncertainty, timing spread, or
conflicting signals materially affect how the reader should interpret the conflicting signals materially affect how the reader should interpret the
forecast. forecast.
Guidance: 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. products for hazard wording.
- Use the hourly forecast and precipitation timing modules for timing details. - Use the hourly forecast and precipitation timing modules for timing details.
- Use current conditions only for immediate context; do not let them override - Use current conditions only for immediate context; do not let them override
the forecast. the forecast.
- Use AFD key messages and short-term discussion for context, but keep regional - Use AFD key messages and short-term discussion for forecast reasoning, but
or broad discussion tied back to the configured location and valid period. 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. - 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 Markdown headings, bullets, or code fences.
- Do not include fields outside the schema. - Do not include fields outside the schema.
- If a field cannot be supported by the data, keep it brief and conservative. - 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 { if err != nil {
t.Fatalf("Template() error = %v", err) 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) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }
@@ -38,10 +38,10 @@ func TestSchemaLookup(t *testing.T) {
if schema.AdditionalProperties { if schema.AdditionalProperties {
t.Fatal("additionalProperties = true, want false") t.Fatal("additionalProperties = true, want false")
} }
if strings.Join(schema.Required, ",") != "summary,timing,impacts" { if strings.Join(schema.Required, ",") != "summary,forecast_discussion" {
t.Fatalf("required = %#v, want summary/timing/impacts", schema.Required) 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) property, ok := schema.Properties[field].(map[string]any)
if !ok { if !ok {
t.Fatalf("schema property %q missing or invalid", field) t.Fatalf("schema property %q missing or invalid", field)
@@ -58,34 +58,36 @@ func TestRenderHourly(t *testing.T) {
Title: "Hourly Report", Title: "Hourly Report",
LocationName: "Brentwood", LocationName: "Brentwood",
ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM", 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{ GeneratedText: testGeneratedText{
Summary: "Storm chances increase through late morning.", Summary: "Storm chances increase through late morning.",
Timing: "The main window is 10 AM to noon.", ForecastDiscussion: "A front will keep the region unsettled.",
Impacts: "Brief downpours may slow travel.", PrecipitationTiming: "A cold front is moving into the region.",
Confidence: "Medium confidence in timing.", Confidence: "Medium confidence in timing.",
}, },
Modules: testModules{ Modules: testModules{
CurrentConditions: &testCurrentConditions{ CurrentConditions: &testCurrentConditions{
ConditionText: "Partly cloudy", ConditionText: "Partly cloudy",
ConditionTextLower: "partly cloudy",
TemperatureF: floatPtr(74), TemperatureF: floatPtr(74),
ApparentTemperatureF: floatPtr(76), ApparentTemperatureF: floatPtr(76),
RelativeHumidityPercent: floatPtr(71), RelativeHumidityPercent: floatPtr(71),
WindDirection: "S", WindDirection: "S",
WindDirectionText: "south",
WindSpeedMph: floatPtr(8), WindSpeedMph: floatPtr(8),
}, },
HourlyForecast: &testHourlyForecast{ HourlyForecast: &testHourlyForecast{
Periods: []testHourlyPeriod{ Periods: []testHourlyPeriod{
{PeriodBegins: "9 AM", TextDescription: "Cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), WindDirection: "S", WindSpeedMph: floatPtr(8)}, {HourLabel: "9:00 AM", TextDescription: "Cloudy", TextDescriptionLower: "cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(19), WindDirection: "S", WindSpeedMph: floatPtr(8)},
{PeriodBegins: "10 AM", TextDescription: "Showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), WindDirection: "S", WindSpeedMph: floatPtr(10)}, {HourLabel: "10:00 AM", TextDescription: "Showers", TextDescriptionLower: "showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), MentionPrecipitation: true, WindDirection: "S", WindSpeedMph: floatPtr(10)},
}, },
}, },
PrecipTiming: &testPrecipTiming{ PrecipTiming: &testPrecipTiming{
MaxPopPercent: intPtr(70), MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM", MaxPopTime: "10 AM",
PrecipitationWindows: []testPrecipWindow{ 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{ AlertDigest: &testAlertDigest{
@@ -114,34 +116,71 @@ func TestRenderHourly(t *testing.T) {
text := string(rendered) text := string(rendered)
for _, want := range []string{ for _, want := range []string{
"# Hourly Report", "# 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.", "Storm chances increase through late morning.",
"## Confidence", "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.",
"- 10 AM: Showers; 75 F; 70% precipitation; wind S 10 mph", "## Active Alerts",
"- Flood Watch: Flood Watch until 2:30 PM (Moderate)", "- **Flood Watch**: Flood Watch until 2:30 PM",
"Short-term discussion favors increasing rain coverage.", "- **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) { if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text) 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{ assertOrderedText(t, text, []string{
"# Hourly Report", "# Hourly Report",
"## Summary",
"## Timing",
"## Impacts",
"## Confidence",
"## Current Conditions", "## Current Conditions",
"## Active Alerts",
"## Hourly Forecast", "## Hourly Forecast",
"## Precipitation Timing", "## Precipitation Timing",
"## Alerts",
"## SPC Outlooks",
"## Forecast Discussion", "## 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) { func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) { if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
t.Fatalf("Template() error = %v, want unknown template", err) t.Fatalf("Template() error = %v, want unknown template", err)
@@ -179,8 +218,8 @@ type testReportContext struct {
type testGeneratedText struct { type testGeneratedText struct {
Summary string Summary string
Timing string ForecastDiscussion string
Impacts string PrecipitationTiming string
Confidence string Confidence string
} }
@@ -197,11 +236,13 @@ type testModules struct {
type testCurrentConditions struct { type testCurrentConditions struct {
ConditionText string ConditionText string
ConditionTextLower string
TemperatureF *float64 TemperatureF *float64
ApparentTemperatureF *float64 ApparentTemperatureF *float64
RelativeHumidityPercent *float64 RelativeHumidityPercent *float64
WindSpeedMph *float64 WindSpeedMph *float64
WindDirection string WindDirection string
WindDirectionText string
} }
type testHourlyForecast struct { type testHourlyForecast struct {
@@ -209,14 +250,17 @@ type testHourlyForecast struct {
} }
type testHourlyPeriod struct { type testHourlyPeriod struct {
HourLabel string
PeriodBegins string PeriodBegins string
Name string Name string
TextDescription string TextDescription string
TextDescriptionLower string
TemperatureF *float64 TemperatureF *float64
WindSpeedMph *float64 WindSpeedMph *float64
WindGustMph *float64 WindGustMph *float64
WindDirection string WindDirection string
ProbabilityOfPrecipitationPercent *float64 ProbabilityOfPrecipitationPercent *float64
MentionPrecipitation bool
} }
type testPrecipTiming struct { type testPrecipTiming struct {
@@ -228,9 +272,12 @@ type testPrecipTiming struct {
type testPrecipWindow struct { type testPrecipWindow struct {
PeriodBegins string PeriodBegins string
PeriodBeginsHourLabel string
PeriodEnds string PeriodEnds string
PeriodEndsHourLabel string
MaxPopPercent *int MaxPopPercent *int
MaxPopTime string MaxPopTime string
MaxPopHourLabel string
} }
type testAlertDigest struct { type testAlertDigest struct {

View File

@@ -6,17 +6,16 @@
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"summary", "summary",
"timing", "forecast_discussion"
"impacts"
], ],
"properties": { "properties": {
"summary": { "summary": {
"type": "string" "type": "string"
}, },
"timing": { "forecast_discussion": {
"type": "string" "type": "string"
}, },
"impacts": { "precipitation_timing": {
"type": "string" "type": "string"
}, },
"confidence": { "confidence": {

View File

@@ -6,88 +6,34 @@
## Current Conditions ## Current Conditions
{{ with .Modules.CurrentConditions }} {{ 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 }} {{ else }}
No current conditions available. Current conditions are unavailable.
{{ end }} {{ end }}
{{ with .Modules.AlertDigest }}{{ with .Relevant }}
## Active Alerts
{{ range . }}
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**{{ with .Headline }}: {{ . }}{{ end }}
{{ end }}
{{ end }}{{ end }}
## Hourly Forecast ## Hourly Forecast
{{ with .Modules.HourlyForecast }}{{ range .Periods }} {{ with .Modules.HourlyForecast }}{{ range .Periods }}
- **{{ .Name }}:** {{ .TextDescription }} and {{ .TemperatureF }}. Chance of precipitation {{ .ProbabilityOfPrecipitationPercent }}%. - **{{ 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 }}
{{ end }} {{ else }}
- No hourly forecast rows are available.
{{ end }}{{ else }}
- No hourly forecast rows are available.
{{ end }} {{ end }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing ## Precipitation Timing
{{ with .Modules.PrecipTiming }} {{ range . }}{{ $window := . }}
{{ with .MaxPopPercent }}Peak precipitation probability: {{ . }}%{{ with $.Modules.PrecipTiming.MaxPopTime }} at {{ . }}{{ end }}. - **{{ 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 }}{{ range .PrecipitationWindows }}{{ $window := . }} {{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
- 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.
{{ end }} {{ end }}
{{ end }}{{ 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 }}
## Forecast Discussion ## Forecast Discussion
{{ with .Modules.AreaForecastDiscussion }}{{ range .KeyMessages }}
- {{ . }}
{{ end }}{{ with .ShortTerm }}
{{ . }} {{ .GeneratedText.ForecastDiscussion }}
{{ 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 }}

View File

@@ -232,7 +232,7 @@ func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SaveGeneratedTextResult() error = %v", err) 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 { if err != nil {
t.Fatalf("SaveGeneratedText() error = %v", err) t.Fatalf("SaveGeneratedText() error = %v", err)
} }
@@ -263,7 +263,7 @@ func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LoadGeneratedText() generated error = %v", err) 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) t.Fatalf("generated text = %q, want saved normalized JSON", generatedData)
} }
var runResult struct { var runResult struct {