diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md new file mode 100644 index 0000000..49637fb --- /dev/null +++ b/docs/internal/generatedtext.md @@ -0,0 +1,73 @@ +# Generated Text Internals + +This document describes structured generated-text handling in +`internal/generatedtext`. + +## Purpose + +`internal/generatedtext` validates structured text returned for +generated-text-template reports and builds curated render contexts for +templates. The first implemented contract is the Hourly Report. + +## Inputs And Outputs + +Inputs: + +- raw Hourly Report GeneratedText JSON +- report metadata from `internal/briefing` +- a module snapshot from `internal/module` +- validated hourly generated text + +Outputs: + +- typed `Hourly` generated text +- normalized stable JSON for validated hourly generated text +- typed `HourlyRenderContext` values for `internal/reporttemplate` + +The hourly generated text JSON accepts: + +```json +{ + "summary": "string", + "timing": "string", + "impacts": "string", + "confidence": "string" +} +``` + +`summary`, `timing`, and `impacts` are required after trimming whitespace. +`confidence` is optional and omitted from normalized JSON when blank. + +## Boundaries + +- This package owns typed generated-text validation and render-context shaping. +- It uses typed module snapshot decoding through `module.StanzaValue`. +- It does not invoke Scriptorium, write state artifacts, choose report + definitions, compare snapshots, or render templates directly in production + workflows. +- It does not use a Go JSON Schema dependency; schema enforcement in Go is + limited to typed JSON decoding, unknown-field rejection, and required-field + checks. + +## Failure Behavior + +- Malformed generated-text JSON fails with decode context. +- Unknown generated-text JSON fields fail during decoding. +- Empty required hourly fields fail after trimming whitespace. +- Missing required render-context stanzas fail with the stanza name. +- Invalid render metadata, including missing timezone, missing generated time, + or invalid valid period, fails before template rendering. + +## Tests + +Inspect: + +- `internal/generatedtext/hourly_test.go` +- `internal/generatedtext/render_context_test.go` + +## Invariants + +- Render contexts are curated structs, not raw prompt-input packages. +- Required generated text is normalized before downstream artifact storage. +- Missing optional weather narrative stanzas produce empty or fallback render + context fields rather than forcing raw module data into templates. diff --git a/internal/generatedtext/hourly.go b/internal/generatedtext/hourly.go new file mode 100644 index 0000000..f7aa8a3 --- /dev/null +++ b/internal/generatedtext/hourly.go @@ -0,0 +1,55 @@ +// Package generatedtext validates structured LLM text and render contexts. +package generatedtext + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" +) + +type Hourly struct { + Summary string `json:"summary"` + Timing string `json:"timing"` + Impacts string `json:"impacts"` + Confidence string `json:"confidence,omitempty"` +} + +func ValidateHourly(data []byte) (Hourly, []byte, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + + var value Hourly + if err := decoder.Decode(&value); err != nil { + return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != nil { + if err != io.EOF { + return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err) + } + } else { + return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values") + } + + value.Summary = strings.TrimSpace(value.Summary) + value.Timing = strings.TrimSpace(value.Timing) + value.Impacts = strings.TrimSpace(value.Impacts) + value.Confidence = strings.TrimSpace(value.Confidence) + if value.Summary == "" { + return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required") + } + if value.Timing == "" { + return Hourly{}, nil, fmt.Errorf("hourly generated text timing is required") + } + if value.Impacts == "" { + return Hourly{}, nil, fmt.Errorf("hourly generated text impacts is required") + } + + normalized, err := json.Marshal(value) + if err != nil { + return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", err) + } + return value, normalized, nil +} diff --git a/internal/generatedtext/hourly_test.go b/internal/generatedtext/hourly_test.go new file mode 100644 index 0000000..2aae71b --- /dev/null +++ b/internal/generatedtext/hourly_test.go @@ -0,0 +1,86 @@ +package generatedtext + +import ( + "strings" + "testing" +) + +func TestValidateHourlyNormalizesJSON(t *testing.T) { + value, normalized, err := ValidateHourly([]byte(`{ + "timing": " main window late morning ", + "summary": " Storm chances increase. ", + "confidence": " Medium ", + "impacts": " Brief downpours. " + }`)) + if err != nil { + t.Fatalf("ValidateHourly() error = %v", err) + } + if value.Summary != "Storm chances increase." { + t.Fatalf("Summary = %q, want trimmed summary", value.Summary) + } + want := `{"summary":"Storm chances increase.","timing":"main window late morning","impacts":"Brief downpours.","confidence":"Medium"}` + if string(normalized) != want { + t.Fatalf("normalized = %s, want %s", normalized, want) + } +} + +func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) { + _, normalized, err := ValidateHourly([]byte(`{ + "summary": "Storm chances increase.", + "timing": "Late morning.", + "impacts": "Brief downpours.", + "confidence": " " + }`)) + if err != nil { + t.Fatalf("ValidateHourly() error = %v", err) + } + want := `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}` + if string(normalized) != want { + t.Fatalf("normalized = %s, want %s", normalized, want) + } +} + +func TestValidateHourlyRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "malformed", + in: `{`, + want: "decode hourly generated text", + }, + { + name: "unknown field", + in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours.","extra":"value"}`, + want: `unknown field "extra"`, + }, + { + name: "missing summary", + in: `{"timing":"Late morning.","impacts":"Brief downpours."}`, + want: "summary is required", + }, + { + name: "blank timing", + in: `{"summary":"Storm chances increase.","timing":" ","impacts":"Brief downpours."}`, + want: "timing is required", + }, + { + name: "multiple values", + in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."} {}`, + want: "multiple JSON values", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, _, err := ValidateHourly([]byte(test.in)) + if err == nil { + t.Fatal("ValidateHourly() error = nil, want error") + } + if !strings.Contains(err.Error(), test.want) { + t.Fatalf("ValidateHourly() error = %v, want %q", err, test.want) + } + }) + } +} diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go new file mode 100644 index 0000000..6685135 --- /dev/null +++ b/internal/generatedtext/render_context.go @@ -0,0 +1,360 @@ +package generatedtext + +import ( + "fmt" + "strings" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "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 +} + +type HourlyForecastRow struct { + Time string + Summary string + Temperature string + Precipitation string + Wind string +} + +type ForecastDiscussion struct { + KeyMessages []string + ShortTerm string +} + +func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) { + location, err := timeutil.LoadLocation(metadata.Timezone) + if err != nil { + return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err) + } + if metadata.GeneratedAt.IsZero() { + return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required") + } + 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)) + 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, + }, + SPCDiscussions: spcDiscussionLabels(spcDiscussion), + WeatherStory: weatherStoryLabel(story), + }, nil +} + +func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) { + value, ok, err := module.StanzaValue[T](snapshot, name) + if err != nil { + var zero T + return zero, fmt.Errorf("build hourly render context: %w", err) + } + if !ok { + var zero T + return zero, fmt.Errorf("build hourly render context requires stanza %q", name) + } + return value, nil +} + +func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) { + value, _, err := module.StanzaValue[T](snapshot, name) + if err != nil { + var zero T + return zero, fmt.Errorf("build hourly render context: %w", err) + } + return value, nil +} + +func locationName(metadata briefing.Metadata) string { + if metadata.Location != nil { + if metadata.Location.Name != "" && metadata.Location.Region != "" { + return metadata.Location.Name + ", " + metadata.Location.Region + } + if metadata.Location.Name != "" { + return metadata.Location.Name + } + } + if metadata.SourceLocation != "" { + return metadata.SourceLocation + } + if metadata.SourceLocationID != "" { + return metadata.SourceLocationID + } + return "Unknown location" +} + +func periodLabel(period timeutil.Period, location *time.Location) string { + return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location)) +} + +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 "" +} diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go new file mode 100644 index 0000000..67e1cab --- /dev/null +++ b/internal/generatedtext/render_context_test.go @@ -0,0 +1,249 @@ +package generatedtext + +import ( + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestBuildHourlyRenderContext(t *testing.T) { + metadata := testMetadata() + snapshot := testSnapshot(t) + generated := Hourly{ + Summary: "Storm chances increase through late morning.", + Timing: "The main window is 10 AM to noon.", + Impacts: "Brief downpours may slow travel.", + Confidence: "Medium confidence in timing.", + } + ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated) + 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.LocationName != "Brentwood, MO" { + t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.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.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 len(ctx.HourlyForecast) != 2 { + t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.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 !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") { + t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming) + } + if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" { + t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts) + } + 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.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." { + t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm) + } + if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." { + t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions) + } + if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." { + t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory) + } + + rendered, err := reporttemplate.Render("hourly", ctx) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, want := range []string{ + "# Hourly Report", + "Storm chances increase through late morning.", + "- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph", + "- Flood Watch: Flood Watch until early afternoon (Moderate)", + "Morning storms - Morning storms remain the main story.", + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered template missing %q:\n%s", want, text) + } + } +} + +func TestBuildHourlyRenderContextRequiresCoreStanzas(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{ + Summary: "Storm chances increase.", + Timing: "Late morning.", + Impacts: "Brief downpours.", + }) + if err == nil { + t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error") + } + if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) { + t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err) + } +} + +func testMetadata() briefing.Metadata { + generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) + return briefing.Metadata{ + RunID: "run-1", + ReportID: report.Hourly, + PromptID: "weather.hourly_generated_text", + GeneratedAt: generatedAt, + Units: "imperial", + Timezone: "America/Chicago", + ValidPeriod: timeutil.Period{ + Start: generatedAt, + End: generatedAt.Add(6 * time.Hour), + }, + Location: &briefing.LocationContext{ + Name: "Brentwood", + Region: "MO", + }, + } +} + +func testSnapshot(t *testing.T) module.Snapshot { + t.Helper() + snapshot, err := module.NewSnapshot([]module.Output{ + { + ID: module.CurrentConditions, + StanzaName: string(module.CurrentConditions), + Value: briefing.CurrentConditionsModule{ + ConditionText: "Partly cloudy", + TemperatureF: floatPtr(74), + ApparentTemperatureF: floatPtr(76), + RelativeHumidityPercent: floatPtr(71), + WindSpeedMph: floatPtr(8), + WindDirection: "S", + }, + }, + { + ID: module.HourlyForecast, + StanzaName: string(module.HourlyForecast), + Value: briefing.HourlyForecastModule{ + Periods: []briefing.HourlyForecastPeriod{ + { + PeriodBegins: "2026-05-29 at 9:00 AM", + TextDescription: "Cloudy", + TemperatureF: floatPtr(74), + ProbabilityOfPrecipitationPercent: floatPtr(30), + WindSpeedMph: floatPtr(8), + WindDirection: "S", + }, + { + PeriodBegins: "2026-05-29 at 10:00 AM", + TextDescription: "Showers", + TemperatureF: floatPtr(75), + ProbabilityOfPrecipitationPercent: floatPtr(70), + WindSpeedMph: floatPtr(10), + WindGustMph: floatPtr(18), + WindDirection: "S", + }, + }, + }, + }, + { + ID: module.PrecipTiming, + StanzaName: string(module.PrecipTiming), + Value: briefing.PrecipTimingModule{ + MaxPopPercent: intPtr(70), + MaxPopTime: "10 AM", + ProbabilityThreshold: 50, + PrecipitationWindows: []briefing.PrecipitationWindowModule{ + { + PeriodBegins: "2026-05-29 at 10:00 AM", + PeriodEnds: "2026-05-29 at 12:00 PM", + MaxPopPercent: intPtr(70), + MaxPopTime: "10 AM", + }, + }, + ThunderMentioned: true, + }, + }, + { + ID: module.AlertDigest, + StanzaName: string(module.AlertDigest), + Value: briefing.AlertDigestModule{ + Checked: true, + ActiveCount: 1, + RelevantCount: 1, + Relevant: []briefing.AlertSummary{ + {Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"}, + }, + }, + }, + { + ID: module.SPCConvectiveOutlooks, + StanzaName: string(module.SPCConvectiveOutlooks), + Value: briefing.SPCConvectiveOutlooksModule{ + Checked: true, + OutlookCount: 1, + Outlooks: []briefing.SPCConvectiveOutlookRecord{ + { + Label: "SLGT", + LabelText: "Slight Risk", + PeriodBegins: "2026-05-29 at 7:00 AM", + PeriodEnds: "2026-05-29 at 3:00 PM", + }, + }, + }, + }, + { + ID: module.AreaForecastDiscussion, + StanzaName: string(module.AreaForecastDiscussion), + Value: briefing.AreaForecastDiscussionModule{ + KeyMessages: []string{"Storms are most likely late morning."}, + ShortTerm: "Short-term discussion favors increasing rain coverage.", + }, + }, + { + ID: module.SPCConvectiveDiscussion, + StanzaName: string(module.SPCConvectiveDiscussion), + Value: briefing.SPCConvectiveDiscussionModule{ + IncludedBecause: "categorical severity_rank >= 3", + Discussions: []briefing.SPCConvectiveDiscussionRecord{ + {Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."}, + }, + }, + }, + { + ID: module.WeatherStory, + StanzaName: string(module.WeatherStory), + Value: briefing.WeatherStoryModule{ + Available: true, + Title: "Morning storms", + Description: "Morning storms remain the main story.", + }, + }, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + return snapshot +} + +func floatPtr(value float64) *float64 { + return &value +} + +func intPtr(value int) *int { + return &value +}