Refactor the template variable framework

This commit is contained in:
2026-06-14 08:57:53 -05:00
parent bb8de054dc
commit 28b8391d53
8 changed files with 512 additions and 552 deletions

View File

@@ -2,44 +2,45 @@ package generatedtext
import (
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type HourlyRenderContext struct {
ReportTitle string
LocationName string
ValidPeriod string
GeneratedAt string
GeneratedText Hourly
CurrentConditions string
HourlyForecast []HourlyForecastRow
PrecipitationTiming string
Alerts []string
SPCOutlooks []string
ForecastDiscussion ForecastDiscussion
SPCDiscussions []string
WeatherStory string
Report HourlyReportContext
GeneratedText Hourly
Modules HourlyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type HourlyForecastRow struct {
Time string
Summary string
Temperature string
Precipitation string
Wind string
type HourlyReportContext struct {
Title string
LocationName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
ValidPeriodLabel string
Timezone string
}
type ForecastDiscussion struct {
KeyMessages []string
ShortTerm string
type HourlyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
}
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) {
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
@@ -50,80 +51,87 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
if !metadata.ValidPeriod.IsValid() {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
}
current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
modules, err := hourlyTemplateModules(snapshot)
if err != nil {
return HourlyRenderContext{}, err
}
hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
if err != nil {
return HourlyRenderContext{}, err
}
precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
if err != nil {
return HourlyRenderContext{}, err
}
alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
if err != nil {
return HourlyRenderContext{}, err
}
outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
if err != nil {
return HourlyRenderContext{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
if err != nil {
return HourlyRenderContext{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
if err != nil {
return HourlyRenderContext{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
if err != nil {
return HourlyRenderContext{}, err
}
return HourlyRenderContext{
ReportTitle: "Hourly Report",
LocationName: locationName(metadata),
ValidPeriod: periodLabel(metadata.ValidPeriod, location),
GeneratedAt: timeLabel(metadata.GeneratedAt, location),
GeneratedText: generated,
CurrentConditions: currentConditionsLabel(current),
HourlyForecast: hourlyForecastRows(hourly),
PrecipitationTiming: precipitationTimingLabel(precip),
Alerts: alertLabels(alerts),
SPCOutlooks: outlookLabels(outlooks),
ForecastDiscussion: ForecastDiscussion{
KeyMessages: append([]string(nil), discussion.KeyMessages...),
ShortTerm: discussion.ShortTerm,
Report: HourlyReportContext{
Title: "Hourly Report",
LocationName: locationName(metadata),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
Timezone: metadata.Timezone,
},
SPCDiscussions: spcDiscussionLabels(spcDiscussion),
WeatherStory: weatherStoryLabel(story),
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, ok, err := module.StanzaValue[T](snapshot, name)
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
if err != nil {
var zero T
return zero, fmt.Errorf("build hourly render context: %w", err)
return HourlyTemplateModules{}, err
}
if !ok {
var zero T
return zero, fmt.Errorf("build hourly render context requires stanza %q", name)
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
if err != nil {
return HourlyTemplateModules{}, err
}
return value, nil
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
if err != nil {
return HourlyTemplateModules{}, err
}
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
if err != nil {
return HourlyTemplateModules{}, err
}
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
if err != nil {
return HourlyTemplateModules{}, err
}
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
if err != nil {
return HourlyTemplateModules{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
if err != nil {
return HourlyTemplateModules{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
if err != nil {
return HourlyTemplateModules{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
if err != nil {
return HourlyTemplateModules{}, err
}
return HourlyTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
}, nil
}
func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) {
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
output, ok := snapshot.LookupStanza(name)
if !ok || output.Value == nil {
return nil, nil
}
value, _, err := module.StanzaValue[T](snapshot, name)
if err != nil {
var zero T
return zero, fmt.Errorf("build hourly render context: %w", err)
return nil, fmt.Errorf("build hourly render context: %w", err)
}
return value, nil
return &value, nil
}
func locationName(metadata briefing.Metadata) string {
@@ -151,210 +159,3 @@ 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 currentConditionsLabel(current briefing.CurrentConditionsModule) string {
parts := []string{}
if current.ConditionText != "" {
parts = append(parts, current.ConditionText)
}
if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" {
parts = append(parts, temperature)
}
if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" {
parts = append(parts, "feels like "+apparent)
}
if current.RelativeHumidityPercent != nil {
parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent)))
}
if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" {
parts = append(parts, wind)
}
if len(parts) == 0 {
return "No current conditions available."
}
return strings.Join(parts, "; ") + "."
}
func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow {
rows := make([]HourlyForecastRow, 0, len(hourly.Periods))
for _, period := range hourly.Periods {
summary := period.TextDescription
if summary == "" {
summary = period.Name
}
rows = append(rows, HourlyForecastRow{
Time: firstNonEmpty(period.PeriodBegins, period.Name),
Summary: summary,
Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC),
Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent),
Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh),
})
}
return rows
}
func precipitationTimingLabel(timing briefing.PrecipTimingModule) string {
parts := []string{}
if timing.MaxPopPercent != nil {
max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent)
if timing.MaxPopTime != "" {
max += " at " + timing.MaxPopTime
}
parts = append(parts, max)
}
for _, window := range timing.PrecipitationWindows {
label := window.PeriodBegins
if window.PeriodEnds != "" {
label += " to " + window.PeriodEnds
}
if window.MaxPopPercent != nil {
label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent)
if window.MaxPopTime != "" {
label += " at " + window.MaxPopTime
}
label += ")"
}
parts = append(parts, label)
}
if timing.ThunderMentioned {
parts = append(parts, "Thunder is mentioned in the forecast.")
}
if len(parts) == 0 {
return "No precipitation timing signal above threshold."
}
return strings.Join(parts, "; ")
}
func alertLabels(alerts briefing.AlertDigestModule) []string {
if alerts.Missing {
return []string{"Alert source missing."}
}
out := make([]string, 0, len(alerts.Relevant))
for _, alert := range alerts.Relevant {
main := firstNonEmpty(alert.Event, alert.Headline)
if main == "" {
continue
}
if alert.Headline != "" && alert.Headline != main {
main += ": " + alert.Headline
}
if alert.Severity != "" {
main += " (" + alert.Severity + ")"
}
out = append(out, main)
}
return out
}
func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string {
out := make([]string, 0, len(outlooks.Outlooks))
for _, outlook := range outlooks.Outlooks {
label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType)
if label == "" {
continue
}
if outlook.PeriodBegins != "" {
label += " from " + outlook.PeriodBegins
if outlook.PeriodEnds != "" {
label += " to " + outlook.PeriodEnds
}
}
out = append(out, label)
}
return out
}
func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string {
out := make([]string, 0, len(discussion.Discussions))
for _, record := range discussion.Discussions {
label := firstNonEmpty(record.Headline, record.Summary, record.Discussion)
if label == "" {
continue
}
if record.Summary != "" && record.Summary != label {
label += ": " + record.Summary
}
out = append(out, label)
}
return out
}
func weatherStoryLabel(story briefing.WeatherStoryModule) string {
if !story.Available {
return "No weather story available."
}
parts := []string{}
if story.Title != "" {
parts = append(parts, story.Title)
}
if story.Description != "" {
parts = append(parts, story.Description)
}
if len(parts) == 0 {
return "Weather story is available."
}
return strings.Join(parts, " - ")
}
func temperatureLabel(fahrenheit *float64, celsius *float64) string {
if fahrenheit != nil {
return fmt.Sprintf("%d F", rounded(*fahrenheit))
}
if celsius != nil {
return fmt.Sprintf("%d C", rounded(*celsius))
}
return ""
}
func precipitationLabel(percent *float64) string {
if percent == nil {
return ""
}
return fmt.Sprintf("%d%% precipitation", rounded(*percent))
}
func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string {
speed := ""
if mph != nil {
speed = fmt.Sprintf("%d mph", rounded(*mph))
} else if kmh != nil {
speed = fmt.Sprintf("%d km/h", rounded(*kmh))
}
if direction != "" && speed != "" {
speed = direction + " " + speed
} else if direction != "" {
speed = direction + " wind"
}
gust := ""
if gustMph != nil {
gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph))
} else if gustKmh != nil {
gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh))
}
switch {
case speed != "" && gust != "":
return "wind " + speed + ", " + gust
case speed != "":
return "wind " + speed
case gust != "":
return "wind " + gust
default:
return ""
}
}
func rounded(value float64) int {
if value < 0 {
return int(value - 0.5)
}
return int(value + 0.5)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -6,6 +6,8 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
@@ -21,45 +23,53 @@ func TestBuildHourlyRenderContext(t *testing.T) {
Impacts: "Brief downpours may slow travel.",
Confidence: "Medium confidence in timing.",
}
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
collected := testCollected()
derived := testDerived()
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived)
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if ctx.ReportTitle != "Hourly Report" {
t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle)
if ctx.Report.Title != "Hourly Report" {
t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title)
}
if ctx.LocationName != "Brentwood, MO" {
t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName)
if ctx.Report.LocationName != "Brentwood, MO" {
t.Fatalf("Report.LocationName = %q, want Brentwood, MO", ctx.Report.LocationName)
}
if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod)
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.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." {
t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions)
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)
}
if len(ctx.HourlyForecast) != 2 {
t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast))
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast)
}
if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" {
t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row)
if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period)
}
if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") {
t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming)
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
}
if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" {
t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts)
if ctx.Modules.AlertDigest == nil || len(ctx.Modules.AlertDigest.Relevant) != 1 || ctx.Modules.AlertDigest.Relevant[0].Event != "Flood Watch" {
t.Fatalf("Modules.AlertDigest = %#v, want alert", ctx.Modules.AlertDigest)
}
if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" {
t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks)
if ctx.Modules.SPCConvectiveOutlooks == nil || len(ctx.Modules.SPCConvectiveOutlooks.Outlooks) != 1 || ctx.Modules.SPCConvectiveOutlooks.Outlooks[0].LabelText != "Slight Risk" {
t.Fatalf("Modules.SPCConvectiveOutlooks = %#v, want outlook", ctx.Modules.SPCConvectiveOutlooks)
}
if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm)
if ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.AreaForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
t.Fatalf("Modules.AreaForecastDiscussion = %#v, want discussion text", ctx.Modules.AreaForecastDiscussion)
}
if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." {
t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions)
if ctx.Modules.SPCConvectiveDiscussion == nil || len(ctx.Modules.SPCConvectiveDiscussion.Discussions) != 1 || ctx.Modules.SPCConvectiveDiscussion.Discussions[0].Headline != "Mesoscale discussion" {
t.Fatalf("Modules.SPCConvectiveDiscussion = %#v, want discussion", ctx.Modules.SPCConvectiveDiscussion)
}
if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." {
t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory)
if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" {
t.Fatalf("Modules.WeatherStory = %#v, want story", ctx.Modules.WeatherStory)
}
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if !ctx.Derived.PrecipTiming.ThunderMentioned {
t.Fatalf("Derived.PrecipTiming.ThunderMentioned = false, want true")
}
rendered, err := reporttemplate.Render("hourly", ctx)
@@ -80,23 +90,23 @@ func TestBuildHourlyRenderContext(t *testing.T) {
}
}
func TestBuildHourlyRenderContextRequiresCoreStanzas(t *testing.T) {
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
Timing: "Late morning.",
Impacts: "Brief downpours.",
})
if err == nil {
t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error")
}, testCollected(), facts.DerivedFacts{})
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) {
t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err)
if ctx.Modules.HourlyForecast != nil {
t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast)
}
}
@@ -120,6 +130,14 @@ func testMetadata() briefing.Metadata {
}
}
func testCollected() facts.CollectedFacts {
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
}
func testDerived() facts.DerivedFacts {
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
}
func testSnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{